From fe3f54a69d9e71e5ac6a26a6d631007cd919794b Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 19:08:02 +0900 Subject: [PATCH 1/3] fix: deliver verified local recovery and schedule workflows --- .github/workflows/flutter_test.yml | 2 + CONTEXT.md | 14 +- analysis_options.yaml | 2 + .../club/devkor/ontime/AlarmLaunchPayload.kt | 11 +- .../club/devkor/ontime/BackupExportPlugin.kt | 363 +- .../club/devkor/ontime/BackupImportPlugin.kt | 244 + .../kotlin/club/devkor/ontime/MainActivity.kt | 5 + .../devkor/ontime/NativeAlarmBootReceiver.kt | 10 + build.yaml | 10 + docs/Architecture.md | 17 +- ...1-anchor-schedules-to-a-named-time-zone.md | 46 + ...reserve-local-data-when-migration-fails.md | 15 + ...0028-require-a-verified-restore-preview.md | 52 + ...2id-and-authenticated-stream-encryption.md | 4 + ...covery-store-and-key-as-one-active-pair.md | 15 + ...-receipts-to-local-aggregate-identities.md | 2 + ...res-and-fence-runtime-by-store-identity.md | 31 + .../screenshots/u01-en-confirm-200.png | Bin 0 -> 61008 bytes .../flutter/screenshots/u01-en-db-failure.png | Bin 0 -> 37027 bytes .../screenshots/u01-en-following-200.png | Bin 0 -> 59797 bytes .../screenshots/u01-en-following-end-200.png | Bin 0 -> 75882 bytes .../screenshots/u01-en-partial-200.png | Bin 0 -> 52792 bytes .../screenshots/u01-ko-confirm-200.png | Bin 0 -> 71606 bytes .../screenshots/u01-ko-following-200.png | Bin 0 -> 75225 bytes .../screenshots/u01-ko-following-end-200.png | Bin 0 -> 88032 bytes .../screenshots/u01-ko-partial-200.png | Bin 0 -> 41506 bytes .../screenshots/u03-en-cleanup-200.png | Bin 0 -> 60722 bytes .../screenshots/u03-en-cleanup-retry-200.png | Bin 0 -> 51870 bytes .../flutter/screenshots/u03-en-delayed.png | Bin 0 -> 46480 bytes .../flutter/screenshots/u03-en-failed-off.png | Bin 0 -> 52118 bytes .../flutter/screenshots/u03-en-failed-on.png | Bin 0 -> 52525 bytes .../screenshots/u03-en-read-error-200.png | Bin 0 -> 58230 bytes .../u03-en-read-error-retry-200.png | Bin 0 -> 51870 bytes .../flutter/screenshots/u03-en-saved-200.png | Bin 0 -> 56407 bytes .../flutter/screenshots/u03-en-unknown.png | Bin 0 -> 42882 bytes .../screenshots/u03-ko-cleanup-200.png | Bin 0 -> 55242 bytes .../screenshots/u03-ko-cleanup-retry-200.png | Bin 0 -> 47055 bytes .../screenshots/u03-ko-read-error-200.png | Bin 0 -> 52705 bytes .../u03-ko-read-error-retry-200.png | Bin 0 -> 47055 bytes .../flutter/screenshots/u03-ko-saved-200.png | Bin 0 -> 49921 bytes ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AlarmLaunchPayload.swift | 10 +- ios/Runner/AppDelegate.swift | 9 + ios/Runner/BackupExportPlugin.swift | 334 +- ios/Runner/BackupImportPlugin.swift | 283 + .../backup_authenticated_time_review.dart | 516 ++ lib/core/backup/backup_content.dart | 749 +++ lib/core/backup/backup_crypto.dart | 447 +- lib/core/backup/backup_export_snapshot.dart | 696 ++ lib/core/backup/backup_file_export_port.dart | 197 +- lib/core/backup/backup_file_import_port.dart | 155 + lib/core/backup/backup_ingestion_store.dart | 842 +++ lib/core/backup/backup_json_reader.dart | 381 ++ lib/core/backup/backup_limits.dart | 119 + lib/core/backup/backup_private_files.dart | 5 + lib/core/backup/backup_recurrence_scan.dart | 169 + .../backup/backup_recurring_time_review.dart | 563 ++ lib/core/backup/backup_service.dart | 1202 ++-- .../backup/backup_time_conflict_world.dart | 141 + .../backup/backup_validated_ingestion.dart | 1736 +++++ lib/core/backup/backup_value_validation.dart | 102 + lib/core/backup/recurring_backup_data.dart | 7 +- lib/core/backup/restore_staging.dart | 135 + lib/core/database/database.dart | 92 +- .../database/encrypted_database_guard.dart | 90 + lib/core/database/initial_store_guard.dart | 2 + .../database/initial_store_guard_native.dart | 233 + .../database/initial_store_guard_web.dart | 9 + lib/core/database/installation_key_store.dart | 55 +- .../database/local_data_files_native.dart | 4 + lib/core/database/local_data_files_web.dart | 2 + lib/core/database/local_data_lifecycle.dart | 72 +- .../database/local_data_operation_gate.dart | 27 +- lib/core/database/local_reset_actions.dart | 19 +- lib/core/database/open_database_native.dart | 42 +- .../recovery/encrypted_pair_database.dart | 115 + .../recovery/native_process_identity.dart | 12 + lib/core/database/recovery/pair_files.dart | 453 ++ .../database/recovery/pair_key_store.dart | 79 + .../recovery/recovery_restore_service.dart | 966 +++ lib/core/database/recovery/store_pair.dart | 116 + .../database/restore_delivery_cleanup.dart | 72 + .../database/restore_runtime_identity.dart | 125 + lib/core/database/schema_contract.dart | 258 + .../database/schema_contracts.schema.json | 5591 +++++++++++++++++ lib/core/database/sqlcipher_loader.dart | 11 + .../services/alarm_operation_coordinator.dart | 75 +- .../services/alarm_registration_cleanup.dart | 45 +- ...ailed_notification_preference_service.dart | 79 +- .../services/local_time_zone_service.dart | 16 +- lib/core/services/notification_content.dart | 4 + lib/core/services/notification_routing.dart | 7 +- lib/core/services/notification_service.dart | 4 +- .../services/notification_tap_router.dart | 15 +- .../services/runtime_privacy_migration.dart | 19 +- .../startup/startup_dependency_scope.dart | 144 + lib/core/startup/startup_failure.dart | 40 + lib/core/startup/subscription_cleanup.dart | 56 + lib/core/time/civil_time_resolver.dart | 82 +- lib/core/time/device_civil_day.dart | 14 + .../recurring_time_correction_mapping.dart | 398 ++ lib/core/time/schedule_time_resolution.dart | 103 + lib/core/time/time_correction_conflicts.dart | 353 ++ lib/core/time/time_zone_rules.dart | 58 + .../duration_json_converters.dart | 27 +- .../local_data_workflow_adapters.dart | 18 +- lib/data/daos/preparation_template_dao.dart | 19 + lib/data/daos/schedule_dao.dart | 101 +- .../daos/schedule_owned_content_cleanup.dart | 80 + ...early_start_session_local_data_source.dart | 16 +- ...eparation_with_time_local_data_source.dart | 8 + .../mappers/domain_persistence_mappers.dart | 5 + lib/data/mappers/recurrence_codec.dart | 17 +- .../repositories/alarm_repository_impl.dart | 15 +- .../default_preferences_repository_impl.dart | 211 + ...earest_schedule_query_repository_impl.dart | 483 ++ .../preparation_repository_impl.dart | 141 +- .../preparation_template_repository_impl.dart | 26 +- .../recurring_schedule_repository_impl.dart | 168 +- ...rring_time_correction_repository_impl.dart | 791 +++ .../schedule_aggregate_repository_impl.dart | 196 +- .../schedule_repository_impl.dart | 253 +- ...edule_time_correction_repository_impl.dart | 363 ++ .../time_correction_conflict_reader.dart | 312 + .../repositories/user_repository_impl.dart | 109 +- .../tables/schedule_with_place_model.dart | 9 +- lib/data/tables/schedules_table.dart | 2 + lib/data/tables/user_table.dart | 4 + lib/domain/entities/alarm_entities.dart | 14 +- lib/domain/entities/backup_operation.dart | 139 +- lib/domain/entities/backup_processing.dart | 173 + .../backup_recurring_time_review.dart | 107 + .../entities/backup_restore_selection.dart | 5 + lib/domain/entities/backup_time_review.dart | 145 + lib/domain/entities/civil_date_time.dart | 94 + .../entities/civil_time_occurrence.dart | 14 + lib/domain/entities/default_preferences.dart | 84 + .../entities/nearest_schedule_query.dart | 197 + .../entities/notification_route_payload.dart | 9 +- lib/domain/entities/schedule_deletion.dart | 63 + lib/domain/entities/schedule_entity.dart | 45 +- .../entities/schedule_time_correction.dart | 46 + .../entities/schedule_time_resolution.dart | 43 + .../schedule_with_preparation_entity.dart | 58 +- .../entities/time_correction_conflict.dart | 73 + lib/domain/ports/backup_file_export_port.dart | 29 + lib/domain/ports/backup_file_import_port.dart | 13 + lib/domain/ports/backup_time_review_port.dart | 7 + .../ports/recovery_preclaim_cleanup_port.dart | 4 + lib/domain/ports/recovery_restore_port.dart | 9 + lib/domain/recurrence/recurrence_engine.dart | 128 +- .../recurrence_reference_policy.dart | 53 + lib/domain/recurrence/recurrence_rule.dart | 15 + .../recurrence/recurring_time_correction.dart | 113 + .../default_preferences_repository.dart | 13 + .../nearest_schedule_query_repository.dart | 18 + .../recurring_schedule_repository.dart | 6 + .../recurring_time_correction_repository.dart | 10 + .../schedule_aggregate_repository.dart | 8 + .../repositories/schedule_repository.dart | 7 +- .../schedule_time_correction_repository.dart | 12 + .../default_preferences_workflow.dart | 110 + .../use-cases/delete_schedule_use_case.dart | 247 +- .../use-cases/finish_schedule_use_case.dart | 24 +- ...t_schedules_with_preparation_use_case.dart | 40 +- ...et_nearest_upcoming_schedule_use_case.dart | 269 +- .../load_schedule_form_draft_use_case.dart | 15 +- .../use-cases/local_data_workflows.dart | 33 +- .../use-cases/reconcile_alarms_use_case.dart | 137 +- .../recurring_schedules_use_case.dart | 35 +- .../recurring_time_correction_workflow.dart | 67 + .../use-cases/schedule_form_submission.dart | 6 + ...schedule_preparation_session_use_case.dart | 154 +- .../schedule_time_correction_workflow.dart | 67 + lib/l10n/app_en.arb | 153 +- lib/l10n/app_ko.arb | 153 +- lib/l10n/app_localizations.dart | 858 +++ lib/l10n/app_localizations_en.dart | 487 ++ lib/l10n/app_localizations_ko.dart | 465 ++ lib/main.dart | 131 +- .../app/bloc/schedule/schedule_bloc.dart | 788 ++- .../app/bloc/schedule/schedule_event.dart | 91 +- .../app/bloc/schedule/schedule_state.dart | 37 + lib/presentation/app/screens/app.dart | 10 +- .../backup/backup_processing_panel.dart | 297 + .../backup_recurring_time_review_screen.dart | 687 ++ .../backup/backup_time_review_screen.dart | 709 +++ .../calendar/bloc/monthly_schedules_bloc.dart | 1 + .../component/schedule_deletion_dialog.dart | 246 + .../calendar/component/schedule_detail.dart | 367 +- .../calendar/screens/calendar_screen.dart | 58 +- .../home/bloc/weekly_schedules_bloc.dart | 12 +- .../home/bloc/weekly_schedules_state.dart | 33 +- .../home/components/device_day_refresh.dart | 73 + .../home/components/month_calendar.dart | 33 +- .../nearest_schedule_query_status.dart | 91 + .../home/components/todays_schedule_tile.dart | 107 +- .../home/screens/home_screen_tmp.dart | 195 +- .../utils/home_schedule_card_selection.dart | 71 + .../detailed_notification_settings_cubit.dart | 344 + .../detailed_notification_settings_state.dart | 44 + lib/presentation/my_page/my_data_screen.dart | 212 +- lib/presentation/my_page/my_page_screen.dart | 254 +- ...ault_preparation_spare_time_form_bloc.dart | 216 +- ...ult_preparation_spare_time_form_event.dart | 26 +- ...ult_preparation_spare_time_form_state.dart | 52 +- .../preparation_spare_time_edit_screen.dart | 204 +- .../recurring/recurrence_components.dart | 24 +- .../recurrence_occurrence_sheet.dart | 6 +- .../recurring/recurrence_review_sheet.dart | 231 +- .../recurring/recurrence_settings_sheet.dart | 43 +- .../recurrence_time_choice_sheet.dart | 23 + .../recurring_management_screen.dart | 96 +- ...ecurring_time_correction_plan_summary.dart | 399 ++ .../recurring_time_correction_screen.dart | 688 ++ .../bloc/schedule_form_bloc.dart | 488 +- .../bloc/schedule_form_event.dart | 40 +- .../bloc/schedule_form_state.dart | 25 +- .../bloc/schedule_time_save_review.dart | 29 + .../components/schedule_multi_page_form.dart | 32 +- .../cubit/schedule_date_time_cubit.dart | 89 +- .../cubit/schedule_date_time_state.dart | 38 +- .../screens/schedule_date_time_form.dart | 267 +- .../screens/schedule_time_zone_picker.dart | 213 + .../screens/schedule_edit_screen.dart | 35 + .../schedule_time_correction_screen.dart | 569 ++ .../schedule_time_save_review_dialog.dart | 63 + .../components/bottom_nav_bar_scaffold.dart | 125 +- .../components/civil_date_time_picker.dart | 277 + .../shared/time/date_basis_info.dart | 36 + .../shared/time/device_time_zone_scope.dart | 144 + .../shared/time/device_time_zone_state.dart | 22 + .../shared/time/schedule_zoned_time.dart | 75 + .../shared/time/schedule_zoned_time_text.dart | 107 + .../shared/time/time_zone_catalog.dart | 70 + .../time/zoned_schedule_presentation.dart | 119 + .../startup/screens/local_startup_gate.dart | 321 +- .../screens/recovery_restore_screen.dart | 490 ++ linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 - .../README.md | 19 + .../source-manifest.json | 3937 ++++++++++++ .../verification.json | 63 + pubspec.lock | 82 +- pubspec.yaml | 9 +- test/core/backup/backup_admission_test.dart | 81 + ...backup_authenticated_time_review_test.dart | 594 ++ .../backup/backup_civil_persistence_test.dart | 193 + .../backup/backup_crypto_capacity_test.dart | 94 + .../backup_crypto_failure_matrix_test.dart | 389 ++ .../backup/backup_crypto_stream_test.dart | 261 + .../backup/backup_export_snapshot_test.dart | 449 ++ .../backup_failure_retry_acceptance_test.dart | 239 + .../backup/backup_file_export_port_test.dart | 259 +- .../backup/backup_file_import_port_test.dart | 221 + .../backup/backup_ingestion_store_test.dart | 167 + .../backup_instant_representation_test.dart | 273 + test/core/backup/backup_json_reader_test.dart | 134 + .../backup/backup_large_ingestion_test.dart | 215 + .../backup_parser_budget_acceptance_test.dart | 94 + ...kup_recurring_time_review_cipher_test.dart | 316 + .../backup_recurring_time_review_test.dart | 575 ++ .../backup/backup_restore_picker_test.dart | 286 +- .../backup_restore_stale_contract_test.dart | 10 + .../backup_retained_recurrence_test.dart | 383 ++ test/core/backup/backup_service_test.dart | 92 +- .../backup_spare_time_boundary_test.dart | 71 + .../backup_time_review_service_test.dart | 548 ++ .../backup/backup_unknown_history_test.dart | 171 + .../backup_unknown_recurrence_test.dart | 212 + .../backup_validated_ingestion_test.dart | 427 ++ .../backup_validation_contract_test.dart | 93 + ...ion_backup_generation_acceptance_test.dart | 294 + ...store_commit_response_acceptance_test.dart | 700 +++ .../restore_replacement_protocol_test.dart | 591 ++ .../backup/restore_staging_cipher_test.dart | 392 ++ .../backup/schedule_deletion_backup_test.dart | 175 + .../alarm_replacement_integration_test.dart | 41 +- .../cipher_capability_guard_test.dart | 71 + test/core/database/database_index_test.dart | 5 +- .../database/encrypted_schema_roles_test.dart | 252 + .../database/historical_row_image_test.dart | 109 + .../database/initial_store_cipher_test.dart | 95 + .../database/initial_store_guard_test.dart | 350 ++ .../local_data_lifecycle_startup_test.dart | 146 + .../database/local_reset_actions_test.dart | 10 +- .../migration_atomic_boundary_test.dart | 85 + .../database/migration_checkpoint_test.dart | 147 + .../database/migration_sqlite_fault_test.dart | 142 + .../native_process_identity_test.dart | 52 + .../recovery_pair_authority_test.dart | 315 + .../recovery_restore_service_test.dart | 1064 ++++ .../recovery_time_review_service_test.dart | 339 + .../restore_delivery_cleanup_test.dart | 99 + .../restore_runtime_quiescence_test.dart | 162 + .../restore_startup_classification_test.dart | 169 + .../schedule_aggregate_migration_test.dart | 51 +- test/core/database/schema_contract_test.dart | 158 + ...rt_restore_generation_acceptance_test.dart | 420 ++ .../alarm_scheduler_service_test.dart | 6 +- .../civil_legacy_runtime_migration_test.dart | 285 + ...tification_preference_acceptance_test.dart | 264 + .../services/notification_routing_test.dart | 4 +- .../notification_tap_router_test.dart | 41 +- .../scheduled_notification_delivery_test.dart | 2 +- .../startup_dependency_scope_test.dart | 113 + .../startup/startup_product_graph_test.dart | 101 + .../startup/subscription_cleanup_test.dart | 53 + test/core/time/civil_time_resolver_test.dart | 169 +- test/core/time/device_civil_day_test.dart | 157 + ...ecurring_time_correction_mapping_test.dart | 277 + .../time/schedule_time_resolution_test.dart | 364 ++ .../time/time_correction_conflicts_test.dart | 240 + test/core/time/time_zone_rules_test.dart | 211 + .../daos/civil_schedule_storage_test.dart | 83 + test/data/daos/schedule_dao_test.dart | 1 + ...alarm_registry_local_data_source_test.dart | 2 +- .../schedule_local_data_source_test.dart | 12 +- .../domain_persistence_mappers_test.dart | 3 + ..._preferences_profile_publication_test.dart | 94 + .../default_preferences_repository_test.dart | 768 +++ .../first_start_time_capture_test.dart | 361 ++ .../nearest_automatic_start_guard_test.dart | 331 + .../nearest_query_sql_boundaries_test.dart | 302 + ...earest_schedule_query_repository_test.dart | 559 ++ .../preparation_restore_watch_test.dart | 199 + .../profile_write_preservation_test.dart | 35 + .../recurring_schedule_repository_test.dart | 27 +- ...rring_time_correction_repository_test.dart | 754 +++ .../recurring_time_override_test.dart | 183 + .../schedule_aggregate_snapshot_test.dart | 10 + .../schedule_deletion_privacy_test.dart | 303 + .../schedule_deletion_repository_test.dart | 525 ++ .../schedule_restore_watch_test.dart | 109 + .../schedule_start_transaction_test.dart | 5 + ...edule_time_correction_repository_test.dart | 630 ++ .../schedule_time_review_commit_test.dart | 308 + .../single_time_correction_conflict_test.dart | 322 + .../time_correction_conflict_reader_test.dart | 207 + ...rical_recurrence_materialization_test.dart | 139 + .../user_restore_identity_test.dart | 124 + .../entities/backup_processing_test.dart | 93 + test/domain/entities/backup_receipt_test.dart | 37 + .../civil_date_time_contract_test.dart | 234 + .../preparation_snapshot_validation_test.dart | 5 +- .../preparation_timing_entity_test.dart | 33 +- .../resolved_schedule_runtime_test.dart | 132 + ...ent_schedule_instant_integration_test.dart | 272 + ...ailed_notification_content_guard_test.dart | 116 + ...notification_delivery_acceptance_test.dart | 527 ++ ...arest_upcoming_schedule_use_case_test.dart | 401 +- ...oad_schedule_form_draft_use_case_test.dart | 2 +- .../use-cases/local_data_workflows_test.dart | 10 + .../nearest_schedule_clock_test.dart | 100 + .../reconcile_alarms_use_case_test.dart | 5 +- .../recurring_alarm_integration_test.dart | 111 + .../schedule_date_use_cases_test.dart | 3 +- .../schedule_deletion_use_case_test.dart | 506 ++ .../schedule_mutation_use_cases_test.dart | 25 +- ...ule_preparation_session_use_case_test.dart | 12 + .../timed_preparation_use_cases_test.dart | 57 +- test/fixtures/database/README.md | 24 +- test/fixtures/database/populated_local.sql | 11 + .../fixtures/database/populated_recurring.sql | 6 + test/fixtures/database/schema_v3.sql | 65 + test/fixtures/database/schema_v4.sql | 65 + test/helpers/c08_home_query_fixture.dart | 48 + test/helpers/fake_clock.dart | 62 + test/helpers/nearest_query_fixture.dart | 30 + test/helpers/restore_staging_fixture.dart | 26 + test/helpers/schedule_deletion_fixture.dart | 159 + .../schedule_deletion_workflow_fixture.dart | 219 + test/helpers/u02_save_fixture.dart | 230 + test/native/AlarmLaunchPayloadTest.kt | 10 +- test/native/AlarmLaunchPayloadTest.swift | 11 +- .../screens/preparation_flow_widget_test.dart | 537 +- .../nearest_active_run_lifecycle_test.dart | 434 ++ .../nearest_boundary_intent_test.dart | 191 + .../nearest_cancel_delivery_race_test.dart | 181 + ...arest_committed_start_projection_test.dart | 332 + .../nearest_query_lifecycle_test.dart | 187 + .../notification_prompt_race_test.dart | 16 +- .../preparation_step_delivery_test.dart | 56 +- .../app/bloc/schedule/schedule_bloc_test.dart | 327 +- .../backup/backup_processing_panel_test.dart | 73 + ...kup_recurring_time_review_screen_test.dart | 585 ++ .../backup_time_review_screen_test.dart | 709 +++ .../bloc/monthly_schedules_bloc_test.dart | 2 + .../schedule_deletion_dialog_test.dart | 594 ++ .../component/schedule_detail_test.dart | 184 +- .../screens/calendar_screen_test.dart | 50 +- .../event_value_objects_test.dart | 10 +- .../home/bloc/device_today_schedule_test.dart | 108 + .../home/bloc/weekly_schedules_bloc_test.dart | 7 +- .../home/home_device_day_test.dart | 504 ++ .../home/nearest_query_home_test.dart | 453 ++ .../home/screens/home_screen_tmp_test.dart | 50 +- ...iled_notification_settings_cubit_test.dart | 355 ++ .../my_data_recurring_time_review_test.dart | 130 + .../my_page/my_data_restore_test.dart | 14 +- .../my_page/my_data_time_review_test.dart | 155 + .../my_page/my_data_unknown_restore_test.dart | 76 + .../my_page/my_page_screen_test.dart | 515 +- ...eparation_spare_time_edit_screen_test.dart | 623 +- .../recurrence_settings_sheet_test.dart | 223 + ...rrence_sheet_title_accessibility_test.dart | 64 + .../recurring_form_integration_test.dart | 133 +- .../recurring/recurring_screens_test.dart | 57 +- ...ing_time_correction_plan_summary_test.dart | 371 ++ ...recurring_time_correction_screen_test.dart | 613 ++ .../recurring/refreshed_screens_test.dart | 49 +- ...ecurrence_acceptance_integration_test.dart | 539 ++ ...following_zone_draft_integration_test.dart | 173 + .../bloc/schedule_form_bloc_test.dart | 155 +- .../bloc/schedule_form_state_event_test.dart | 29 +- ...e_time_review_sqlite_integration_test.dart | 319 + .../schedule_multi_page_form_test.dart | 131 +- .../schedule_date_time_cubit_test.dart | 141 +- .../schedule_date_time_form_test.dart | 89 +- ...hedule_time_zone_picker_contract_test.dart | 317 + ...schedule_time_save_review_dialog_test.dart | 140 + .../schedule_time_correction_screen_test.dart | 466 ++ .../u02_form_acceptance_integration_test.dart | 321 + .../u02_form_authority_integration_test.dart | 236 + .../civil_date_time_picker_test.dart | 262 + .../notification_launch_widget_test.dart | 6 +- .../time/device_time_zone_scope_test.dart | 137 + ...zoned_recurrence_review_contract_test.dart | 311 + ...d_schedule_presentation_contract_test.dart | 375 ++ .../zoned_schedule_surface_contract_test.dart | 138 + .../startup/damaged_store_restore_test.dart | 26 + .../local_startup_store_recovery_test.dart | 353 ++ .../migration_recovery_integration_test.dart | 101 + .../startup/recovery_restore_screen_test.dart | 330 + .../startup/recovery_time_review_test.dart | 554 ++ tool/check_database_schema.dart | 29 + tool/database_schema_builder.dart | 33 + tool/database_schema_input.dart | 125 + tool/qa/android_sqlite_control.kt | 38 + tool/qa/backup_native_probe.dart | 399 ++ tool/qa/capture_damaged_store_recovery.dart | 130 + tool/qa/capture_default_preferences.dart | 122 + tool/qa/capture_restore_cleanup.dart | 199 + tool/qa/emit_database_schema.dart | 32 + tool/qa/migration_crash_probe.dart | 167 + tool/qa/migration_native_probe.dart | 217 + tool/qa/recovery_pair_crash_probe.dart | 187 + tool/qa/schedule_deletion_crash_probe.dart | 135 + tool/qa/time_zone_native_probe.dart | 684 ++ tool/qa/verify_migration_crash_recovery.py | 58 + tool/qa/verify_pair_crash_recovery.py | 53 + ...verify_schedule_deletion_crash_recovery.py | 71 + tool/qa/verify_schema_gate.py | 53 + .../flutter/generated_plugin_registrant.cc | 8 - .../linux/flutter/generated_plugins.cmake | 2 - .../Flutter/GeneratedPluginRegistrant.swift | 4 - widgetbook/pubspec.lock | 140 +- .../flutter/generated_plugin_registrant.cc | 6 - .../windows/flutter/generated_plugins.cmake | 2 - .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 462 files changed, 81842 insertions(+), 4024 deletions(-) create mode 100644 android/app/src/main/kotlin/club/devkor/ontime/BackupImportPlugin.kt create mode 100644 build.yaml create mode 100644 docs/adr/0038-stage-restores-and-fence-runtime-by-store-identity.md create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-en-confirm-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-en-db-failure.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-en-following-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-en-following-end-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-en-partial-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-ko-confirm-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-ko-following-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-ko-following-end-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u01-ko-partial-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-cleanup-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-cleanup-retry-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-delayed.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-failed-off.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-failed-on.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-read-error-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-read-error-retry-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-saved-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-en-unknown.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-ko-cleanup-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-ko-cleanup-retry-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-ko-read-error-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-ko-read-error-retry-200.png create mode 100644 docs/design/redesign-20260923/flutter/screenshots/u03-ko-saved-200.png create mode 100644 ios/Runner/BackupImportPlugin.swift create mode 100644 lib/core/backup/backup_authenticated_time_review.dart create mode 100644 lib/core/backup/backup_content.dart create mode 100644 lib/core/backup/backup_export_snapshot.dart create mode 100644 lib/core/backup/backup_file_import_port.dart create mode 100644 lib/core/backup/backup_ingestion_store.dart create mode 100644 lib/core/backup/backup_json_reader.dart create mode 100644 lib/core/backup/backup_limits.dart create mode 100644 lib/core/backup/backup_private_files.dart create mode 100644 lib/core/backup/backup_recurrence_scan.dart create mode 100644 lib/core/backup/backup_recurring_time_review.dart create mode 100644 lib/core/backup/backup_time_conflict_world.dart create mode 100644 lib/core/backup/backup_validated_ingestion.dart create mode 100644 lib/core/backup/backup_value_validation.dart create mode 100644 lib/core/backup/restore_staging.dart create mode 100644 lib/core/database/encrypted_database_guard.dart create mode 100644 lib/core/database/initial_store_guard.dart create mode 100644 lib/core/database/initial_store_guard_native.dart create mode 100644 lib/core/database/initial_store_guard_web.dart create mode 100644 lib/core/database/recovery/encrypted_pair_database.dart create mode 100644 lib/core/database/recovery/native_process_identity.dart create mode 100644 lib/core/database/recovery/pair_files.dart create mode 100644 lib/core/database/recovery/pair_key_store.dart create mode 100644 lib/core/database/recovery/recovery_restore_service.dart create mode 100644 lib/core/database/recovery/store_pair.dart create mode 100644 lib/core/database/restore_delivery_cleanup.dart create mode 100644 lib/core/database/restore_runtime_identity.dart create mode 100644 lib/core/database/schema_contract.dart create mode 100644 lib/core/database/schema_contracts.schema.json create mode 100644 lib/core/database/sqlcipher_loader.dart create mode 100644 lib/core/startup/startup_dependency_scope.dart create mode 100644 lib/core/startup/startup_failure.dart create mode 100644 lib/core/startup/subscription_cleanup.dart create mode 100644 lib/core/time/device_civil_day.dart create mode 100644 lib/core/time/recurring_time_correction_mapping.dart create mode 100644 lib/core/time/schedule_time_resolution.dart create mode 100644 lib/core/time/time_correction_conflicts.dart create mode 100644 lib/core/time/time_zone_rules.dart create mode 100644 lib/data/daos/schedule_owned_content_cleanup.dart create mode 100644 lib/data/repositories/default_preferences_repository_impl.dart create mode 100644 lib/data/repositories/nearest_schedule_query_repository_impl.dart create mode 100644 lib/data/repositories/recurring_time_correction_repository_impl.dart create mode 100644 lib/data/repositories/schedule_time_correction_repository_impl.dart create mode 100644 lib/data/repositories/time_correction_conflict_reader.dart create mode 100644 lib/domain/entities/backup_processing.dart create mode 100644 lib/domain/entities/backup_recurring_time_review.dart create mode 100644 lib/domain/entities/backup_restore_selection.dart create mode 100644 lib/domain/entities/backup_time_review.dart create mode 100644 lib/domain/entities/civil_date_time.dart create mode 100644 lib/domain/entities/civil_time_occurrence.dart create mode 100644 lib/domain/entities/default_preferences.dart create mode 100644 lib/domain/entities/nearest_schedule_query.dart create mode 100644 lib/domain/entities/schedule_deletion.dart create mode 100644 lib/domain/entities/schedule_time_correction.dart create mode 100644 lib/domain/entities/schedule_time_resolution.dart create mode 100644 lib/domain/entities/time_correction_conflict.dart create mode 100644 lib/domain/ports/backup_file_export_port.dart create mode 100644 lib/domain/ports/backup_file_import_port.dart create mode 100644 lib/domain/ports/backup_time_review_port.dart create mode 100644 lib/domain/ports/recovery_preclaim_cleanup_port.dart create mode 100644 lib/domain/ports/recovery_restore_port.dart create mode 100644 lib/domain/recurrence/recurrence_reference_policy.dart create mode 100644 lib/domain/recurrence/recurring_time_correction.dart create mode 100644 lib/domain/repositories/default_preferences_repository.dart create mode 100644 lib/domain/repositories/nearest_schedule_query_repository.dart create mode 100644 lib/domain/repositories/recurring_time_correction_repository.dart create mode 100644 lib/domain/repositories/schedule_time_correction_repository.dart create mode 100644 lib/domain/use-cases/default_preferences_workflow.dart create mode 100644 lib/domain/use-cases/recurring_time_correction_workflow.dart create mode 100644 lib/domain/use-cases/schedule_time_correction_workflow.dart create mode 100644 lib/presentation/backup/backup_processing_panel.dart create mode 100644 lib/presentation/backup/backup_recurring_time_review_screen.dart create mode 100644 lib/presentation/backup/backup_time_review_screen.dart create mode 100644 lib/presentation/calendar/component/schedule_deletion_dialog.dart create mode 100644 lib/presentation/home/components/device_day_refresh.dart create mode 100644 lib/presentation/home/components/nearest_schedule_query_status.dart create mode 100644 lib/presentation/home/utils/home_schedule_card_selection.dart create mode 100644 lib/presentation/my_page/cubit/detailed_notification_settings_cubit.dart create mode 100644 lib/presentation/my_page/cubit/detailed_notification_settings_state.dart create mode 100644 lib/presentation/recurring/recurring_time_correction_plan_summary.dart create mode 100644 lib/presentation/recurring/recurring_time_correction_screen.dart create mode 100644 lib/presentation/schedule_create/bloc/schedule_time_save_review.dart create mode 100644 lib/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart create mode 100644 lib/presentation/schedule_create/screens/schedule_time_correction_screen.dart create mode 100644 lib/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart create mode 100644 lib/presentation/shared/components/civil_date_time_picker.dart create mode 100644 lib/presentation/shared/time/date_basis_info.dart create mode 100644 lib/presentation/shared/time/device_time_zone_scope.dart create mode 100644 lib/presentation/shared/time/device_time_zone_state.dart create mode 100644 lib/presentation/shared/time/schedule_zoned_time.dart create mode 100644 lib/presentation/shared/time/schedule_zoned_time_text.dart create mode 100644 lib/presentation/shared/time/time_zone_catalog.dart create mode 100644 lib/presentation/shared/time/zoned_schedule_presentation.dart create mode 100644 lib/presentation/startup/screens/recovery_restore_screen.dart create mode 100644 plans/pr-verified-checkpoint-2026-09-25/README.md create mode 100644 plans/pr-verified-checkpoint-2026-09-25/source-manifest.json create mode 100644 plans/pr-verified-checkpoint-2026-09-25/verification.json create mode 100644 test/core/backup/backup_admission_test.dart create mode 100644 test/core/backup/backup_authenticated_time_review_test.dart create mode 100644 test/core/backup/backup_civil_persistence_test.dart create mode 100644 test/core/backup/backup_crypto_capacity_test.dart create mode 100644 test/core/backup/backup_crypto_failure_matrix_test.dart create mode 100644 test/core/backup/backup_crypto_stream_test.dart create mode 100644 test/core/backup/backup_export_snapshot_test.dart create mode 100644 test/core/backup/backup_failure_retry_acceptance_test.dart create mode 100644 test/core/backup/backup_file_import_port_test.dart create mode 100644 test/core/backup/backup_ingestion_store_test.dart create mode 100644 test/core/backup/backup_instant_representation_test.dart create mode 100644 test/core/backup/backup_json_reader_test.dart create mode 100644 test/core/backup/backup_large_ingestion_test.dart create mode 100644 test/core/backup/backup_parser_budget_acceptance_test.dart create mode 100644 test/core/backup/backup_recurring_time_review_cipher_test.dart create mode 100644 test/core/backup/backup_recurring_time_review_test.dart create mode 100644 test/core/backup/backup_retained_recurrence_test.dart create mode 100644 test/core/backup/backup_spare_time_boundary_test.dart create mode 100644 test/core/backup/backup_time_review_service_test.dart create mode 100644 test/core/backup/backup_unknown_history_test.dart create mode 100644 test/core/backup/backup_unknown_recurrence_test.dart create mode 100644 test/core/backup/backup_validated_ingestion_test.dart create mode 100644 test/core/backup/backup_validation_contract_test.dart create mode 100644 test/core/backup/detailed_notification_backup_generation_acceptance_test.dart create mode 100644 test/core/backup/restore_commit_response_acceptance_test.dart create mode 100644 test/core/backup/restore_replacement_protocol_test.dart create mode 100644 test/core/backup/restore_staging_cipher_test.dart create mode 100644 test/core/backup/schedule_deletion_backup_test.dart create mode 100644 test/core/database/cipher_capability_guard_test.dart create mode 100644 test/core/database/encrypted_schema_roles_test.dart create mode 100644 test/core/database/historical_row_image_test.dart create mode 100644 test/core/database/initial_store_cipher_test.dart create mode 100644 test/core/database/initial_store_guard_test.dart create mode 100644 test/core/database/local_data_lifecycle_startup_test.dart create mode 100644 test/core/database/migration_atomic_boundary_test.dart create mode 100644 test/core/database/migration_checkpoint_test.dart create mode 100644 test/core/database/migration_sqlite_fault_test.dart create mode 100644 test/core/database/native_process_identity_test.dart create mode 100644 test/core/database/recovery_pair_authority_test.dart create mode 100644 test/core/database/recovery_restore_service_test.dart create mode 100644 test/core/database/recovery_time_review_service_test.dart create mode 100644 test/core/database/restore_delivery_cleanup_test.dart create mode 100644 test/core/database/restore_runtime_quiescence_test.dart create mode 100644 test/core/database/restore_startup_classification_test.dart create mode 100644 test/core/database/schema_contract_test.dart create mode 100644 test/core/database/start_restore_generation_acceptance_test.dart create mode 100644 test/core/services/civil_legacy_runtime_migration_test.dart create mode 100644 test/core/services/detailed_notification_preference_acceptance_test.dart create mode 100644 test/core/startup/startup_dependency_scope_test.dart create mode 100644 test/core/startup/startup_product_graph_test.dart create mode 100644 test/core/startup/subscription_cleanup_test.dart create mode 100644 test/core/time/device_civil_day_test.dart create mode 100644 test/core/time/recurring_time_correction_mapping_test.dart create mode 100644 test/core/time/schedule_time_resolution_test.dart create mode 100644 test/core/time/time_correction_conflicts_test.dart create mode 100644 test/core/time/time_zone_rules_test.dart create mode 100644 test/data/daos/civil_schedule_storage_test.dart create mode 100644 test/data/repositories/default_preferences_profile_publication_test.dart create mode 100644 test/data/repositories/default_preferences_repository_test.dart create mode 100644 test/data/repositories/first_start_time_capture_test.dart create mode 100644 test/data/repositories/nearest_automatic_start_guard_test.dart create mode 100644 test/data/repositories/nearest_query_sql_boundaries_test.dart create mode 100644 test/data/repositories/nearest_schedule_query_repository_test.dart create mode 100644 test/data/repositories/preparation_restore_watch_test.dart create mode 100644 test/data/repositories/recurring_time_correction_repository_test.dart create mode 100644 test/data/repositories/recurring_time_override_test.dart create mode 100644 test/data/repositories/schedule_deletion_privacy_test.dart create mode 100644 test/data/repositories/schedule_deletion_repository_test.dart create mode 100644 test/data/repositories/schedule_restore_watch_test.dart create mode 100644 test/data/repositories/schedule_time_correction_repository_test.dart create mode 100644 test/data/repositories/schedule_time_review_commit_test.dart create mode 100644 test/data/repositories/single_time_correction_conflict_test.dart create mode 100644 test/data/repositories/time_correction_conflict_reader_test.dart create mode 100644 test/data/repositories/unknown_historical_recurrence_materialization_test.dart create mode 100644 test/data/repositories/user_restore_identity_test.dart create mode 100644 test/domain/entities/backup_processing_test.dart create mode 100644 test/domain/entities/backup_receipt_test.dart create mode 100644 test/domain/entities/civil_date_time_contract_test.dart create mode 100644 test/domain/entities/resolved_schedule_runtime_test.dart create mode 100644 test/domain/use-cases/adjacent_schedule_instant_integration_test.dart create mode 100644 test/domain/use-cases/detailed_notification_content_guard_test.dart create mode 100644 test/domain/use-cases/detailed_notification_delivery_acceptance_test.dart create mode 100644 test/domain/use-cases/nearest_schedule_clock_test.dart create mode 100644 test/domain/use-cases/schedule_deletion_use_case_test.dart create mode 100644 test/fixtures/database/populated_local.sql create mode 100644 test/fixtures/database/populated_recurring.sql create mode 100644 test/fixtures/database/schema_v3.sql create mode 100644 test/fixtures/database/schema_v4.sql create mode 100644 test/helpers/c08_home_query_fixture.dart create mode 100644 test/helpers/fake_clock.dart create mode 100644 test/helpers/nearest_query_fixture.dart create mode 100644 test/helpers/restore_staging_fixture.dart create mode 100644 test/helpers/schedule_deletion_fixture.dart create mode 100644 test/helpers/schedule_deletion_workflow_fixture.dart create mode 100644 test/helpers/u02_save_fixture.dart create mode 100644 test/presentation/app/bloc/schedule/nearest_active_run_lifecycle_test.dart create mode 100644 test/presentation/app/bloc/schedule/nearest_boundary_intent_test.dart create mode 100644 test/presentation/app/bloc/schedule/nearest_cancel_delivery_race_test.dart create mode 100644 test/presentation/app/bloc/schedule/nearest_committed_start_projection_test.dart create mode 100644 test/presentation/app/bloc/schedule/nearest_query_lifecycle_test.dart create mode 100644 test/presentation/backup/backup_processing_panel_test.dart create mode 100644 test/presentation/backup/backup_recurring_time_review_screen_test.dart create mode 100644 test/presentation/backup/backup_time_review_screen_test.dart create mode 100644 test/presentation/calendar/component/schedule_deletion_dialog_test.dart create mode 100644 test/presentation/home/bloc/device_today_schedule_test.dart create mode 100644 test/presentation/home/home_device_day_test.dart create mode 100644 test/presentation/home/nearest_query_home_test.dart create mode 100644 test/presentation/my_page/detailed_notification_settings_cubit_test.dart create mode 100644 test/presentation/my_page/my_data_recurring_time_review_test.dart create mode 100644 test/presentation/my_page/my_data_time_review_test.dart create mode 100644 test/presentation/my_page/my_data_unknown_restore_test.dart create mode 100644 test/presentation/recurring/recurrence_settings_sheet_test.dart create mode 100644 test/presentation/recurring/recurrence_sheet_title_accessibility_test.dart create mode 100644 test/presentation/recurring/recurring_time_correction_plan_summary_test.dart create mode 100644 test/presentation/recurring/recurring_time_correction_screen_test.dart create mode 100644 test/presentation/recurring/u02_recurrence_acceptance_integration_test.dart create mode 100644 test/presentation/schedule_create/bloc/following_zone_draft_integration_test.dart create mode 100644 test/presentation/schedule_create/bloc/schedule_time_review_sqlite_integration_test.dart create mode 100644 test/presentation/schedule_create/schedule_date_time/schedule_time_zone_picker_contract_test.dart create mode 100644 test/presentation/schedule_create/schedule_time_save_review_dialog_test.dart create mode 100644 test/presentation/schedule_create/time_correction/schedule_time_correction_screen_test.dart create mode 100644 test/presentation/schedule_create/u02_form_acceptance_integration_test.dart create mode 100644 test/presentation/schedule_create/u02_form_authority_integration_test.dart create mode 100644 test/presentation/shared/components/civil_date_time_picker_test.dart create mode 100644 test/presentation/shared/time/device_time_zone_scope_test.dart create mode 100644 test/presentation/shared/time/zoned_recurrence_review_contract_test.dart create mode 100644 test/presentation/shared/time/zoned_schedule_presentation_contract_test.dart create mode 100644 test/presentation/shared/time/zoned_schedule_surface_contract_test.dart create mode 100644 test/presentation/startup/damaged_store_restore_test.dart create mode 100644 test/presentation/startup/local_startup_store_recovery_test.dart create mode 100644 test/presentation/startup/migration_recovery_integration_test.dart create mode 100644 test/presentation/startup/recovery_restore_screen_test.dart create mode 100644 test/presentation/startup/recovery_time_review_test.dart create mode 100644 tool/check_database_schema.dart create mode 100644 tool/database_schema_builder.dart create mode 100644 tool/database_schema_input.dart create mode 100644 tool/qa/android_sqlite_control.kt create mode 100644 tool/qa/backup_native_probe.dart create mode 100644 tool/qa/capture_damaged_store_recovery.dart create mode 100644 tool/qa/capture_default_preferences.dart create mode 100644 tool/qa/capture_restore_cleanup.dart create mode 100644 tool/qa/emit_database_schema.dart create mode 100644 tool/qa/migration_crash_probe.dart create mode 100644 tool/qa/migration_native_probe.dart create mode 100644 tool/qa/recovery_pair_crash_probe.dart create mode 100644 tool/qa/schedule_deletion_crash_probe.dart create mode 100644 tool/qa/time_zone_native_probe.dart create mode 100644 tool/qa/verify_migration_crash_recovery.py create mode 100644 tool/qa/verify_pair_crash_recovery.py create mode 100644 tool/qa/verify_schedule_deletion_crash_recovery.py create mode 100644 tool/qa/verify_schema_gate.py diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index 4ebd3b3a..750af000 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -33,6 +33,8 @@ jobs: run: flutter --version - name: Run generator run: dart run build_runner build --delete-conflicting-outputs + - name: Check historical database schema contracts + run: dart run tool/check_database_schema.dart - name: Check generated Dart policy run: dart run tool/check_generated_dart_policy.dart - name: Check local-only product boundary diff --git a/CONTEXT.md b/CONTEXT.md index d327cabd..d48579ac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,6 +13,14 @@ _Avoid_: Offline-first, local cache mode, standalone mode The single installation-bound, non-identifying profile that owns Schedules, Places, Preparations, outcomes, and preferences. _Avoid_: Account, login, member, OnTime User +**Schedule Draft**: +The editable contents of a Schedule while the user is creating or changing it, before those edits are saved or discarded. +_Avoid_: Saved Schedule, automatic backup, Preparation Run + +**Unsaved Schedule Changes**: +Changes in a Schedule Draft that differ from the editing starting point accepted by the user and have not been saved. Changes already saved remain saved when notification delivery setup is still pending. +_Avoid_: Validation error, pending notification, backup freshness + **Schedule Outcome**: The final local result of completing one Schedule as On Time, Late, or Abnormal. _Avoid_: Server result, analytics event, transient completion screen @@ -202,9 +210,13 @@ The sum of a Schedule's Preparation step durations, excluding move time and Sche _Avoid_: Total duration, travel time, buffer time **Schedule Spare Time**: -A user buffer before a Schedule's appointment time, separate from travel time and Preparation Duration. +A user buffer before a Schedule's appointment time, separate from travel time and Preparation Duration. A Schedule with no assigned buffer has zero spare time; changing the profile default does not change that Schedule's buffer. _Avoid_: Preparation time, move time +**Default Schedule Spare Time**: +The profile's suggested buffer used to initialize a new Schedule draft. It does not replace an absent buffer on an existing or restored Schedule. +_Avoid_: Inherited schedule buffer, automatic buffer override + **Default Preparation**: The user's fallback Preparation for new Schedules. _Avoid_: Base preparation, global preparation diff --git a/analysis_options.yaml b/analysis_options.yaml index a7a37460..b0b80854 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -13,6 +13,8 @@ analyzer: exclude: - build/** - widgetbook/** + # Frozen before-change manifests are evidence, not a runnable package. + - plans/audit-2026-09-23/artifacts/d05/q8-validation/dependency-before/** linter: # The lint rules applied to this project can be customized in the diff --git a/android/app/src/main/kotlin/club/devkor/ontime/AlarmLaunchPayload.kt b/android/app/src/main/kotlin/club/devkor/ontime/AlarmLaunchPayload.kt index cd56b051..363fa658 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/AlarmLaunchPayload.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/AlarmLaunchPayload.kt @@ -7,12 +7,17 @@ object AlarmLaunchPayload { if (id.isBlank() || id.length > 512 || id.any { it.code < 32 || it.code == 127 }) { return null } - return mapOf( + val identity = source["storeIncarnation"] + if (source.containsKey("storeIncarnation") && + (identity !is String || !Regex("^[a-fA-F0-9-]{32,36}$").matches(identity))) { + return null + } + return mutableMapOf( "type" to "schedule_alarm", "scheduleId" to id, - "alarmLaunchPayloadVersion" to "9", + "alarmLaunchPayloadVersion" to "10", "promptVariant" to "alarm", - ) + ).apply { if (identity is String) put("storeIncarnation", identity) } } /** Provider display fields are never forwarded to Flutter as a route. */ diff --git a/android/app/src/main/kotlin/club/devkor/ontime/BackupExportPlugin.kt b/android/app/src/main/kotlin/club/devkor/ontime/BackupExportPlugin.kt index 25fdbdff..3ea0973b 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/BackupExportPlugin.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/BackupExportPlugin.kt @@ -12,14 +12,16 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.PluginRegistry +import java.io.Closeable import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.io.OutputStream +import java.security.MessageDigest import java.util.UUID import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicInteger -/** Exports only the already encrypted container. No destination or grant is retained. */ +/** Bounded ciphertext transfer. Provider URIs and permissions never cross Dart. */ class BackupExportPlugin : FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler { private lateinit var context: Context @@ -27,24 +29,48 @@ class BackupExportPlugin : FlutterPlugin, ActivityAware, private var binding: ActivityPluginBinding? = null private val main = Handler(Looper.getMainLooper()) private var active: Attempt? = null + private var lastReceipt: Pair>? = null - private class Attempt( - val result: MethodChannel.Result, - val requestCode: Int, - val directory: File, - val file: File, - ) { - @Volatile var interrupted = false - var finishing = false - var writing = false + private class Attempt(val id: String, val directory: File, val file: File) { + @Volatile var cancelled = false + @Volatile var sink: OutputStream? = null + @Volatile var saved = false + var busy = false + var closingSink = false + var cleaning = false + var directoryOwned = false + val openHandles = mutableSetOf() // io queue only; failed closes stay owned + var registered = false + var initialized = false // worker-owned until its main-thread completion + var sealed = false + var length = 0L + var sequence = 0L + var requestCode: Int? = null + var pickerPending = false + var outcome: String? = null + var exportResult: MethodChannel.Result? = null + val cancelResults = mutableListOf() } companion object { - // A single queue also orders cleanup from an old engine before a new export. + private const val LIMIT = 72L * 1024 * 1024 + private const val CHUNK = 65536 private val io = Executors.newSingleThreadExecutor() - private val requestCodes = AtomicInteger(24000) - private val liveDirectories = mutableSetOf() // Accessed only on io. - private const val FAILURE = "Backup could not be saved. An incomplete file may remain in the selected location." + // Never queue cancellation behind the provider write it must interrupt. + private val closes = Executors.newCachedThreadPool() + private val liveDirectories = mutableSetOf() // io queue only + private val pendingRequests = mutableSetOf() + @Synchronized private fun reserveRequest(): Int? { + val code = (24000..29999).firstOrNull { it !in pendingRequests } ?: return null + pendingRequests.add(code) + return code + } + @Synchronized private fun releaseRequest(code: Int) { pendingRequests.remove(code) } + private fun integer(value: Any?): Long? = when (value) { + is Int -> value.toLong() + is Long -> value + else -> null + } } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -55,145 +81,286 @@ class BackupExportPlugin : FlutterPlugin, ActivityAware, val root = File(context.noBackupFilesDir, "backup_exports") try { root.listFiles()?.filter { it.path !in liveDirectories }?.forEach { - try { it.deleteRecursively() } catch (_: Exception) { /* Retry next startup. */ } + try { it.deleteRecursively() } catch (_: Exception) { /* Retry startup. */ } } - } catch (_: Exception) { /* Export will report any inaccessible staging directory. */ } + } catch (_: Exception) { /* Owned operation will report I/O failure. */ } } } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - if (call.method != "export") { result.notImplemented(); return } - if (active != null) { - result.error("export_busy", "A backup export is already in progress.", null) - return - } val args = call.arguments as? Map<*, *> - val bytes = args?.get("encryptedBytes") as? ByteArray - val name = args?.get("suggestedName") as? String - if (bytes == null || bytes.isEmpty() || name.isNullOrBlank() || - name.length > 200 || name.contains('/') || name.contains('\\') || - !name.endsWith(".ontimebackup") || binding == null) { - result.error("export_failed", "Backup export is unavailable.", null) + if (call.method == "begin") { + if (active != null) { fail(result, "export_busy"); return } + val name = args?.get("suggestedName") as? String + if (name.isNullOrBlank() || name.length > 200 || name.contains('/') || + name.contains('\\') || name.contains('\u0000') || !name.endsWith(".ontimebackup")) { + fail(result, "export_invalid"); return + } + val id = UUID.randomUUID().toString() + val directory = File(File(context.noBackupFilesDir, "backup_exports"), id) + active = Attempt(id, directory, File(directory, name)) + result.success(id) return } - // Never reuse an ID in this process: an abandoned picker may return late. - // Activity request codes must fit in 16 bits. Exhaustion requires a new process. - if (requestCodes.get() >= 65535) { - result.error("export_failed", "Please restart OnTime before exporting another backup.", null) - return + val id = args?.get("handle") as? String + if (call.method == "cancel" && id != null && id == lastReceipt?.first) { + result.success(lastReceipt!!.second); return } - val directory = File(File(context.noBackupFilesDir, "backup_exports"), UUID.randomUUID().toString()) - val attempt = Attempt(result, requestCodes.incrementAndGet(), directory, File(directory, name)) - active = attempt - io.execute { - try { - liveDirectories.add(directory.path) - if (!directory.mkdirs()) throw IOException() - FileOutputStream(attempt.file).use { stream -> - stream.write(bytes) - stream.flush() - stream.fd.sync() + val attempt = active + if (attempt == null || id != attempt.id) { fail(result, "export_invalid"); return } + if (call.method == "cancel") { cancel(attempt, result); return } + if (attempt.cancelled || attempt.outcome != null) { fail(result, "export_cancelled"); return } + if (attempt.busy || attempt.cleaning || attempt.pickerPending) { fail(result, "export_busy"); return } + when (call.method) { + "append" -> { + val bytes = args["bytes"] as? ByteArray + val sequence = integer(args["sequence"]) + if (attempt.sealed || bytes == null || bytes.isEmpty() || bytes.size > CHUNK || + sequence == null || sequence != attempt.sequence) { + fail(result, "export_invalid"); return } - if (!attempt.file.readBytes().contentEquals(bytes)) throw IOException() - main.post { - if (active === attempt && !attempt.finishing && !attempt.interrupted) { - try { - val activity = binding?.activity ?: throw IOException() - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/octet-stream" - putExtra(Intent.EXTRA_TITLE, name) - } - activity.startActivityForResult(intent, attempt.requestCode) - } catch (_: Exception) { finish(attempt, failed = true) } + if (bytes.size > LIMIT - attempt.length) { fail(result, "export_limit"); return } + work(attempt, result) { + checkCancelled(attempt) + if (!attempt.initialized) { + if (!liveDirectories.add(attempt.directory.path)) throw IOException() + attempt.registered = true + val parent = attempt.directory.parentFile ?: throw IOException() + if (!parent.isDirectory && !parent.mkdirs()) throw IOException() + if (!attempt.directory.mkdir()) throw IOException() + attempt.directoryOwned = true + if (!attempt.file.createNewFile()) throw IOException() + attempt.initialized = true } + ownedUse(attempt, FileOutputStream(attempt.file, true)) { it.write(bytes) } + attempt.length += bytes.size + attempt.sequence++ } - } catch (_: Exception) { - main.post { finish(attempt, failed = true) } + } + "seal" -> { + val length = integer(args["length"]) + val digest = args["sha256"] as? String + if (attempt.sealed || !attempt.initialized || length == null || length !in 1..LIMIT || + length != attempt.length || digest == null || !digest.matches(Regex("[0-9a-f]{64}"))) { + fail(result, "export_invalid"); return + } + work(attempt, result, sealed = true) { + ownedUse(attempt, FileOutputStream(attempt.file, true)) { it.fd.sync() } + val hash = MessageDigest.getInstance("SHA-256") + var read = 0L + ownedUse(attempt, attempt.file.inputStream()) { source -> + val buffer = ByteArray(CHUNK) + while (true) { + checkCancelled(attempt) + val count = source.read(buffer) + if (count < 0) break + if (count == 0 || count > length - read) throw IOException() + hash.update(buffer, 0, count) + read += count + } + } + val actual = hash.digest().joinToString("") { "%02x".format(it.toInt() and 255) } + if (read != length || actual != digest) throw IOException() + } + } + "export" -> { + val activity = binding?.activity + if (!attempt.sealed || activity == null || attempt.exportResult != null) { + fail(result, "export_invalid"); return + } + val request = reserveRequest() + if (request == null) { fail(result, "export_busy"); return } + attempt.requestCode = request + attempt.pickerPending = true + attempt.exportResult = result + try { + activity.startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + putExtra(Intent.EXTRA_TITLE, attempt.file.name) + }, request) + } catch (_: Exception) { + releaseRequest(request) + attempt.pickerPending = false + attempt.outcome = "failed" + finish(attempt) + } + } + else -> result.notImplemented() + } + } + + private fun work(attempt: Attempt, result: MethodChannel.Result, sealed: Boolean = false, block: () -> Unit) { + attempt.busy = true + io.execute { + var failed = false + try { block() } catch (_: Exception) { failed = true } + main.post { + attempt.busy = false + if (failed || attempt.cancelled) { + if (!attempt.cancelled) attempt.outcome = "failed" + fail(result, if (attempt.cancelled) "export_cancelled" else "export_io") + } else { + if (sealed) attempt.sealed = true + result.success(true) + } + finish(attempt) } } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - val attempt = active ?: return false - if (requestCode != attempt.requestCode) return false - if (attempt.finishing || attempt.writing) return true - if (resultCode == Activity.RESULT_CANCELED) { finish(attempt, outcome = "cancelled"); return true } + if (requestCode !in 24000..29999) return false + releaseRequest(requestCode) + val attempt = active + if (attempt == null || requestCode != attempt.requestCode) return false + if (!attempt.pickerPending) return true + attempt.pickerPending = false + if (attempt.cancelled || resultCode == Activity.RESULT_CANCELED) { + attempt.outcome = "cancelled" + finish(attempt) + return true + } val uri = data?.data if (resultCode != Activity.RESULT_OK || uri == null || uri.scheme != "content") { - finish(attempt, failed = true) - return true + attempt.outcome = "failed"; finish(attempt); return true } - attempt.writing = true writeDocument(attempt, uri) return true } private fun writeDocument(attempt: Attempt, uri: Uri) { + attempt.busy = true io.execute { try { - if (attempt.interrupted) throw IOException() + checkCancelled(attempt) val output = context.contentResolver.openOutputStream(uri, "w") ?: throw IOException() - output.use { sink -> - attempt.file.inputStream().use { source -> - val buffer = ByteArray(64 * 1024) + attempt.sink = output + ownedUse(attempt, output) { sink -> + checkCancelled(attempt) + ownedUse(attempt, attempt.file.inputStream()) { source -> + val buffer = ByteArray(CHUNK) while (true) { - if (attempt.interrupted) throw IOException() + checkCancelled(attempt) val count = source.read(buffer) if (count < 0) break sink.write(buffer, 0, count) } } sink.flush() - } // close must succeed before saved is reported. - main.post { finish(attempt, outcome = "saved") } + } + // This fact survives a cancellation racing with the main-thread receipt. + attempt.saved = true } catch (_: Exception) { - // A provider URI alone does not prove ownership/new creation. Never delete it. - main.post { finish(attempt, failed = true) } + // Never delete/truncate a provider URI as cleanup: ownership is unknown. + } finally { + attempt.sink = null + main.post { + attempt.busy = false + attempt.outcome = if (attempt.saved) "saved" else if (attempt.cancelled) "cancelled" else "failed" + finish(attempt) + } } } } - private fun finish(attempt: Attempt, outcome: String? = null, failed: Boolean = false) { - if (active !== attempt || attempt.finishing) return - attempt.finishing = true - // Once write/flush/close has completed, later Activity teardown must not - // turn a known saved file into a failure. Dart guards its own DB generation. - val interruptedBeforeReceipt = attempt.interrupted + private fun cancel(attempt: Attempt, result: MethodChannel.Result?) { + result?.let { attempt.cancelResults.add(it) } + attempt.cancelled = true + if (attempt.outcome == null) attempt.outcome = "cancelled" + if (attempt.pickerPending) { + // No receipt before the picker callback confirms termination. + try { attempt.requestCode?.let { binding?.activity?.finishActivity(it) } } catch (_: Exception) { } + } + val sink = attempt.sink + if (sink != null && !attempt.closingSink) { + attempt.closingSink = true + closes.execute { + try { sink.close() } catch (_: Exception) { /* Worker determines final I/O outcome. */ } + main.post { attempt.closingSink = false; finish(attempt) } + } + } + finish(attempt) + } + + private fun finish(attempt: Attempt) { + if (active !== attempt || attempt.outcome == null || attempt.busy || attempt.pickerPending || + attempt.closingSink || attempt.cleaning) return + // An append failure keeps ownership until Dart explicitly requests cleanup. + if (attempt.exportResult == null && attempt.cancelResults.isEmpty() && !attempt.cancelled) return + attempt.cleaning = true io.execute { - // Startup retries failed local cleanup; an external saved copy remains saved. - try { attempt.directory.deleteRecursively() } catch (_: Exception) { /* Retry next startup. */ } - liveDirectories.remove(attempt.directory.path) + var cleaned = false + try { + // A failed close is not a terminal handle. Retry every owned + // handle, and keep the file and owner if any close still fails. + for (handle in attempt.openHandles.toList()) { + try { handle.close(); attempt.openHandles.remove(handle) } catch (_: Exception) { } + } + if (attempt.openHandles.isNotEmpty()) throw IOException() + cleaned = !attempt.directoryOwned || + ((!attempt.directory.exists() || attempt.directory.deleteRecursively()) && + !attempt.directory.exists()) + } catch (_: Exception) { } + if (cleaned && attempt.registered) liveDirectories.remove(attempt.directory.path) main.post { - if (active !== attempt) return@post - active = null - if (failed || interruptedBeforeReceipt) { - attempt.result.error("export_failed", FAILURE, null) - } else { - attempt.result.success(outcome ?: "cancelled") + attempt.cleaning = false + val receipt = mapOf( + "outcome" to if (attempt.saved) "saved" else (attempt.outcome ?: "cancelled"), + "cleanupUnconfirmed" to !cleaned, + ) + val replies = attempt.cancelResults.toList() + attempt.cancelResults.clear() + val exportReply = attempt.exportResult + attempt.exportResult = null + if (cleaned) { + lastReceipt = attempt.id to receipt + active = null } + exportReply?.success(receipt) + replies.forEach { it.success(receipt) } } } } - private fun detachActivity() { - binding?.removeActivityResultListener(this) - binding = null - active?.let { - it.interrupted = true - finish(it, failed = true) + private fun ownedUse(attempt: Attempt, handle: T, block: (T) -> R): R { + attempt.openHandles.add(handle) + try { return block(handle) } + finally { + handle.close() + attempt.openHandles.remove(handle) } } + private fun checkCancelled(attempt: Attempt) { if (attempt.cancelled) throw IOException() } + private fun fail(result: MethodChannel.Result, code: String) { + result.error(code, "Backup export did not complete. A partial file may remain at the selected location.", null) + } override fun onAttachedToActivity(binding: ActivityPluginBinding) { this.binding = binding binding.addActivityResultListener(this) + active?.takeIf { it.cancelled }?.let { cancel(it, null) } } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = onAttachedToActivity(binding) - override fun onDetachedFromActivityForConfigChanges() = detachActivity() - override fun onDetachedFromActivity() = detachActivity() + override fun onDetachedFromActivityForConfigChanges() { + binding?.removeActivityResultListener(this) + binding = null // Rotation does not establish picker cancellation. + } + override fun onDetachedFromActivity() { + active?.let { + cancel(it, null) + // CREATE_DOCUMENT received only a suggested name, never our spool. + // Removing this listener permanently revokes this selection's ability + // to start a write. Its request slot stays reserved for a late result. + // A worker already writing still must return before finish can run. + it.pickerPending = false + finish(it) + } + binding?.removeActivityResultListener(this) + binding = null + } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - detachActivity() + onDetachedFromActivity() channel?.setMethodCallHandler(null) channel = null } diff --git a/android/app/src/main/kotlin/club/devkor/ontime/BackupImportPlugin.kt b/android/app/src/main/kotlin/club/devkor/ontime/BackupImportPlugin.kt new file mode 100644 index 00000000..0a8425a6 --- /dev/null +++ b/android/app/src/main/kotlin/club/devkor/ontime/BackupImportPlugin.kt @@ -0,0 +1,244 @@ +package club.devkor.ontime + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.provider.OpenableColumns +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry +import java.io.InputStream +import java.io.IOException +import java.util.UUID +import java.util.concurrent.Executors + +/** Narrow backup reader. The URI and its transient permission never cross Dart. */ +class BackupImportPlugin : FlutterPlugin, ActivityAware, + PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler { + private lateinit var context: Context + private var channel: MethodChannel? = null + private var binding: ActivityPluginBinding? = null + private val main = Handler(Looper.getMainLooper()) + private var active: Attempt? = null + private var lastClosed: String? = null + + private class Attempt(val id: String, val requestCode: Int) { + @Volatile var cancelled = false + @Volatile var stream: InputStream? = null + var pick: MethodChannel.Result? = null + var busy = false + var closing = false + var closeFailure = false + var closeResult: MethodChannel.Result? = null + var consumed = 0L + var eof = false + var picked = false + } + companion object { + private const val LIMIT = 72L * 1024 * 1024 + private const val CHUNK = 65536 + // Reuse only after Android has delivered that selection's terminal + // callback. Cancelled pickers with a late callback keep their slot. + private val pendingRequests = mutableSetOf() + @Synchronized private fun reserveRequest(): Int? { + val code = (18000..23999).firstOrNull { it !in pendingRequests } ?: return null + pendingRequests.add(code) + return code + } + @Synchronized private fun releaseRequest(code: Int) { pendingRequests.remove(code) } + private val reads = Executors.newSingleThreadExecutor() + // Cancellation cannot be queued behind a blocking provider read. + private val closes = Executors.newSingleThreadExecutor() + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, "ontime/backup_import") + channel?.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method == "begin") { + if (active != null) { fail(result, "import_busy"); return } + val code = reserveRequest() + if (code == null) { fail(result, "import_busy"); return } + val attempt = Attempt(UUID.randomUUID().toString(), code) + active = attempt + result.success(attempt.id) + return + } + val id = call.argument("handle") + if (call.method == "close" && id != null && id == lastClosed) { + result.success(true); return + } + val attempt = active + if (attempt == null || id != attempt.id) { fail(result); return } + when (call.method) { + "pick" -> { + val activity = binding?.activity + if (activity == null || attempt.picked || attempt.cancelled) { fail(result); return } + attempt.picked = true + attempt.pick = result + try { + activity.startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, attempt.requestCode) + } catch (_: Exception) { releaseRequest(attempt.requestCode); attempt.pick = null; fail(result) } + } + "read" -> { + val rawMaximum = call.argument("maxBytes") + val maximum = when (rawMaximum) { + is Int -> rawMaximum.toLong() + is Long -> rawMaximum + else -> null + } + if (maximum == null || maximum !in 1..CHUNK.toLong() || attempt.busy || + attempt.cancelled || attempt.stream == null || attempt.pick != null) { + fail(result); return + } + if (attempt.eof) { + result.success(mapOf("bytes" to ByteArray(0), "eof" to true)); return + } + attempt.busy = true + val size = minOf(maximum, LIMIT - attempt.consumed + 1L).toInt() + reads.execute { + var bytes: ByteArray? = null + var eof = false + var error: String? = null + try { + if (attempt.cancelled) throw IOException() + val buffer = ByteArray(size) + val count = attempt.stream!!.read(buffer) + when { + count < 0 -> { bytes = ByteArray(0); eof = true } + count == 0 -> error = "import_io" + count.toLong() > LIMIT - attempt.consumed -> error = "import_limit" + else -> bytes = if (count == size) buffer else buffer.copyOf(count) + } + } catch (_: Exception) { error = "import_io" } + main.post { + attempt.busy = false + if (attempt.cancelled) fail(result, "import_cancelled") + else if (error != null) fail(result, error!!) + else { + attempt.consumed += bytes!!.size + attempt.eof = eof + result.success(mapOf("bytes" to bytes, "eof" to eof)) + } + finishClose(attempt) + } + } + } + "close" -> close(attempt, result) + else -> result.notImplemented() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode !in 18000..23999) return false + releaseRequest(requestCode) + val attempt = active + if (attempt == null || requestCode != attempt.requestCode) return false + val reply = attempt.pick ?: return true + attempt.pick = null + if (attempt.cancelled) { fail(reply, "import_cancelled"); return true } + val uri = data?.data + if (resultCode != Activity.RESULT_OK) { reply.success(null); return true } + if (uri == null) { fail(reply); return true } + attempt.busy = true + reads.execute { + var length: Long? = null + var failure: String? = null + try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + val column = cursor.getColumnIndex(OpenableColumns.SIZE) + if (column >= 0 && cursor.moveToFirst() && !cursor.isNull(column)) { + val size = cursor.getLong(column) + if (size >= 0) length = size // Unknown/negative is not an allocation size. + } + } + if (length != null && length!! > LIMIT) failure = "import_limit" + else if (!attempt.cancelled) { + val opened = context.contentResolver.openInputStream(uri) ?: throw IOException() + attempt.stream = opened + // close() may have raced the provider's blocking open call. + if (attempt.cancelled) { + opened.close() + attempt.stream = null + } + } + } catch (_: Exception) { failure = "import_io" } + main.post { + attempt.busy = false + if (attempt.cancelled) fail(reply, "import_cancelled") + else if (failure != null) fail(reply, failure!!) + else reply.success(mapOf("length" to length)) + finishClose(attempt) + } + } + return true + } + + private fun close(attempt: Attempt, result: MethodChannel.Result?) { + if (attempt.closeResult != null) { if (result != null) fail(result, "import_busy"); return } + attempt.closeResult = result + attempt.cancelled = true + if (!attempt.picked) releaseRequest(attempt.requestCode) + attempt.pick?.let { fail(it, "import_cancelled") } + attempt.pick = null + try { binding?.activity?.finishActivity(attempt.requestCode) } catch (_: Exception) { } + if (attempt.closing) return + attempt.closing = true + attempt.closeFailure = false + closes.execute { + try { attempt.stream?.close(); attempt.stream = null } + catch (_: Exception) { attempt.closeFailure = true } + main.post { attempt.closing = false; finishClose(attempt) } + } + } + + private fun finishClose(attempt: Attempt) { + if (!attempt.cancelled || attempt.busy || attempt.closing) return + // An open that returned after the first close needs another actual close. + if (attempt.stream != null && !attempt.closeFailure) { + val reply = attempt.closeResult + attempt.closeResult = null + close(attempt, reply) + return + } + val reply = attempt.closeResult + attempt.closeResult = null + if (attempt.closeFailure) { if (reply != null) fail(reply); return } + if (active === attempt) active = null + lastClosed = attempt.id + reply?.success(true) + } + + private fun fail(result: MethodChannel.Result, code: String = "import_io") { + result.error(code, "Backup input could not be read.", null) + } + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + this.binding = binding + binding.addActivityResultListener(this) + } + override fun onDetachedFromActivityForConfigChanges() = detachActivity() + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = onAttachedToActivity(binding) + override fun onDetachedFromActivity() = detachActivity() + private fun detachActivity() { + active?.let { close(it, null) } + binding?.removeActivityResultListener(this) + binding = null + } + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + active?.let { close(it, null) } + channel?.setMethodCallHandler(null) + channel = null + } +} diff --git a/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt b/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt index ee5418c0..1a28b670 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt @@ -24,6 +24,7 @@ open class MainActivity : FlutterActivity() { private const val CHANNEL_NAME = "on_time_front/native_alarm" const val ACTION_SCHEDULE_ALARM = "on_time_front.SCHEDULE_ALARM" private var launchPayload: Map? = null + private val processIdentity = java.util.UUID.randomUUID().toString() } override fun onCreate(savedInstanceState: Bundle?) { @@ -39,6 +40,9 @@ open class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + if (!flutterEngine.plugins.has(BackupImportPlugin::class.java)) { + flutterEngine.plugins.add(BackupImportPlugin()) + } if (!flutterEngine.plugins.has(BackupExportPlugin::class.java)) { flutterEngine.plugins.add(BackupExportPlugin()) } @@ -49,6 +53,7 @@ open class MainActivity : FlutterActivity() { ) methodChannel?.setMethodCallHandler { call, result -> when (call.method) { + "getProcessIdentity" -> result.success(processIdentity) "getCapabilities" -> { val nativeAlarmApproved = NativeAlarmPolicy.isAndroidFullScreenAlarmApproved() diff --git a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt index b970192c..29322c3f 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt @@ -148,6 +148,11 @@ class NativeAlarmBootReceiver : BroadcastReceiver() { if (preparationStartTimeMillis != null) { extras["preparationStartTime"] = preparationStartTimeMillis.toString() } + // Preserve only the identity originally issued with this registry record. + // Never stamp a legacy record with the current installation identity. + if (record.has("storeIncarnation") && !record.isNull("storeIncarnation")) { + extras["storeIncarnation"] = record.optString("storeIncarnation") + } extras["title"] = record.optString("scheduleTitle", "") extras["body"] = "It is time to get ready." return NativeAlarmReceiver.alarmPendingIntentForRecord( @@ -177,6 +182,11 @@ class NativeAlarmBootReceiver : BroadcastReceiver() { parseAlarmTime(record.optString("preparationStartTime"))?.let { extras["preparationStartTime"] = it.toString() } + // Preserve only the identity originally issued with this registry record. + // Never stamp a legacy record with the current installation identity. + if (record.has("storeIncarnation") && !record.isNull("storeIncarnation")) { + extras["storeIncarnation"] = record.optString("storeIncarnation") + } extras["title"] = record.optString("scheduleTitle", "") extras["body"] = "It is time to get ready." return NativeAlarmReceiver.activityPendingIntentForExtras( diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..09d381bc --- /dev/null +++ b/build.yaml @@ -0,0 +1,10 @@ +builders: + database_schema_contracts: + import: "tool/database_schema_builder.dart" + builder_factories: ["databaseSchemaContracts"] + build_extensions: {".schema.json": [".g.dart"]} + auto_apply: root_package + build_to: source + defaults: + generate_for: + - lib/core/database/schema_contracts.schema.json diff --git a/docs/Architecture.md b/docs/Architecture.md index 31c327ba..e8a95406 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -219,18 +219,11 @@ class GetUserResponseModel { ### 5. Database Layer with Drift -```dart -@DriftDatabase(tables: [Users, Schedules, Places], daos: [UserDao, ScheduleDao]) -class AppDatabase extends _$AppDatabase { - @override - int get schemaVersion => 3; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (Migrator m) async => await m.createAll(), - ); -} -``` +`AppDatabase` uses encrypted local schema 4 and eleven owned tables. The checked-in historical contracts independently describe schemas 1–4; the shared current-version and supported-lineage declaration lives in `lib/core/database/schema_contracts.schema.json`. `build_runner` embeds that input in an ignored generated Dart file, and CI validates its digest and the executable DDL. + +Normal legacy startup may migrate supported schemas 1/2/3 to 4. Active replacement pairs start with schema 4; candidate validation never implicitly migrates. Before writes, openers require SQLCipher, the existing key, and a matching historical schema. Unknown, partial, future, and nonempty version-zero stores remain preserved for Recovery. Creation requires the initial-store guard or explicit owned candidate authority. + +Migration DDL, backfill, schema/foreign-key checks and `user_version` commit in one SQLite transaction. The later Drift version acknowledgement can still fail after a complete commit, so response errors alone do not prove rollback. Foreign-key connection settings remain outside the transaction. See ADR 0023 and the D04 historical fixtures for invariants and evidence scope. ## 🔄 Data Flow diff --git a/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md b/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md index 641e1525..492f56ea 100644 --- a/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md +++ b/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md @@ -18,3 +18,49 @@ Home “today” uses the current device civil day's half-open instant interval, Civil values must be represented without the device's DST normalization. An internal UTC carrier, if used, carries wall fields and is not itself the commitment instant. Existing civil TEXT and backup-format semantics are retained; this is not an instruction to rewrite stored civil values as UTC instants. Restore corrections happen in staging, followed by complete validation and a new verified preview before final apply. Cancelling that flow changes no active data. These are accepted A10 requirements from four recorded root/delegate questions in `plans/audit-2026-09-23/issues/A10.md`; they do not claim that the existing consumers or staging UI already implement them. + + +## A10 implementation clarification — explicit correction preserves protection + +An active-store time-correction command uses the same generation, incarnation, version and idempotent mutation receipt as the aggregate writer. Inside the transaction it re-derives the typed unknown, ambiguous or stale time problem from the current baseline; a client-supplied error label is not authority. The narrow correction is available only for an unstarted, unfrozen, incomplete occurrence whose original instant is not provably past. A passed preparation-start threshold alone may be corrected explicitly only when both the newly chosen occurrence and its preparation start are after the final current UTC instant. This does not expose a generic bypass for name, preparation or place edits and does not invent elapsed time, a run or an outcome. Actual started, frozen, completed and provably historical commitments keep their protection. + +The confirmed future recurrence split and retained closed-segment reference contract is defined in ADR0028. Active changes use the aggregate baseline and mutation authority; staging never invokes an active-store writer. A committed correction whose old owned notification cannot yet be cancelled remains committed with pending delivery cleanup, under the existing single-owner replacement rules. It is not silently rolled back or reported as fully delivered. Device-zone movement updates equivalent display and day boundaries without changing durable occurrence or revision. These additional decisions are recorded in `plans/audit-2026-09-23/a10-implementation-review.md` and are not a claim of implementation or test completion. + +### Capture a previously unselected occurrence at its actual first start + +Reads, countdowns, form loading and notification reconciliation do not persist an inferred offset. A successful first-start command is a narrower boundary: inside the same write transaction it rechecks the current row, protection and closed-reference status, loaded rules and final UTC time. Only a known-zone, unique occurrence that is not already past may have its rule-derived offset recorded together with its first `startedAt` and frozen preparation. This records the actual start's target; it does not copy the device offset. A partial write rolls back the entire change, and a retry preserves the original target and start receipt. An already started/frozen legacy row with a null offset, or an unknown, ambiguous, nonexistent, stale or closed-tail occurrence, cannot use this path as an implicit repair. + +The execution fingerprint represents the same resolved instant and original civil, zone and preparation meaning before and after that atomic null-to-selected-offset transition. It must not invalidate the same run merely because the raw offset was recorded. An actual target change changes the interpretation and requires the existing notification-owner reconciliation rules. The global rule-data identity remains separate review/claim authority; an unrelated rule update must not invalidate a historical run whose target is already frozen. This clarification addresses restart and lateness after the target time passes, when an unrecorded historical null offset would otherwise lose its justified target. The additional question, answer and validation evidence belong in `plans/audit-2026-09-23/a10-implementation-followups.md`; acceptance of the decision does not claim its implementation is complete. + +The not-already-past condition applies to capturing a previously null offset. It does not introduce a blanket ban on an otherwise permitted manual late start with an explicit, preserved offset. Keep the existing start, completion, restore-confirmation and recurrence-retention gates. A runtime interpretation is an immutable view created with one fixed evaluation time; countdowns, timing identity and preparation start use that same interpretation without repeated ambient-clock reads. It does not mutate the stored row or itself authorize a start. Final execution still rechecks current row/version, generation, loaded rules and the final time at the existing command boundary. Modal date, time, zone and recurrence responses also retain the A08 form-owner authority: a delayed response from an older draft cannot edit or validate a replacement draft. + +### Preserve strict legacy runtime compatibility when changing the civil carrier + +The former database converter constructed a local `DateTime`, so its legacy fingerprint serializer emitted civil text without `Z`. A UTC civil carrier must reproduce those exact wall fields for the existing A11 in-memory legacy comparison; appending `Z` merely because the carrier changed would invalidate unchanged progress. Keep complete fingerprint and preparation-step checks. Do not parse arbitrary legacy fingerprint fragments, remove timezone text indiscriminately, or force-match values whose civil meaning actually changed through prior device normalization. A fixed historical literal read against the current SQLite row is the regression fixture, not a fingerprint generated by the new serializer under test. + +A typed unresolved time is an item-specific interpretation problem, not evidence that the entire encrypted database is unavailable. During runtime privacy migration, preserve that schedule and its durable facts, clear only the unverifiable installation-owned transient projection, and allow valid independent items to migrate. The unresolved item gains no automatic start, completion or alarm authority. Actual database, runtime-storage or cleanup failures retain their failure behavior and cannot produce a successful migration marker. These rules do not claim that all migration and native-device checks have passed. + +The domain entity owns only the immutable time interpretation value, status, occurrence and typed unresolved exception. Loaded timezone lookup and rule interpretation remain in the adapter. The factory that combines a stored schedule with preparation requires an explicitly supplied fixed-pass interpretation; it must not consult the device clock or registry implicitly. A direct explicit-offset value remains sufficient for pure domain calculations. This preserves the domain dependency boundary and makes the instant used before and after asynchronous preparation loading identical within the read pass. Architecture tests remain unchanged. + +### Refresh recommendations without inventing or replacing an active run + +An upcoming recommendation has a bounded future query and must be reevaluated when time alone changes eligibility. Device midnight, foreground device-day/zone changes and resume renew that query; obsolete asynchronous preparation reads cannot replace a newer selection. Timer wake-up granularity is separate from civil/instant precision: exact comparisons remain intact while sub-millisecond wake-ups use a nonzero supported timer delay. + +The existing materialized-store stream also supplies durable incomplete runs identified by `isStarted`, `startedAt` and frozen preparation, even when their original target lies outside the future date window. This does not expand historical recurrence or promote partial legacy markers. Completed/deleted rows lose this eligibility through authoritative store changes. A validated runtime projection may continue its original run; a missing projection cannot be replaced with invented elapsed time or an automatic new start. + +The actual routed home uses the device's half-open day for the today card. An incomplete durable run may remain accessible outside today under an explicit in-progress heading; an ordinary tomorrow recommendation is not presented as today's commitment. On date/zone/resume changes the home refreshes today and equivalent displays. A calendar still in its initial automatic today mode follows a new current month, while explicit user month/day navigation is preserved. Earlier tests of the unused weekly home do not constitute evidence for this routed monthly home. These are recorded A10 follow-up decisions; implementation and regression completion remain separately tracked. + + +A closed recurring reference forbids a new automatic start, but does not erase an existing incomplete durable Preparation Run. Nearest/home access may retain a reference with `isStarted`, a recorded `startedAt` and frozen preparation. Resuming its timer still requires a validated runtime snapshot whose start instant agrees with the durable run; a durable marker alone is not authority to synthesize elapsed progress. Missing or inconsistent runtime requires explicit recovery/confirmation and must not cause a background start write or navigation. Completed/deleted rows cease to qualify. Pending work from a replaced nearest subscription cannot overwrite the newer query's result. + + +Time-correction conflict review checks all provable resolved instant/preparation overlaps within the existing bounded proof contract. Unresolved history/completed or closed non-active records do not receive new execution authority and do not block unrelated resolved corrections. An unresolved future/active item that might intersect the reviewed range is disclosed separately as an uncertain impact, using conservative supported civil/offset/preparation bounds; unavailable bounds cannot prove disjointness. It is never described as conflict-free. Correcting that item later requires renewed conflict validation, and its unresolved status cannot grant a new start/alarm. This separation permits active-store corrections and individual authenticated staging edits; it does not grant a staged candidate verified-preview or activation authority while unresolved future requirements remain. Original validated active-run access/completion is preserved under its own authority. + + +A failed nearest/preparation read is not evidence that a Schedule was deleted. The last validated visible run is retained with an explicit retry state; failed date/store inputs cannot reuse stale cached authority, and no new automatic or explicit start may derive from that failed projection. A later authoritative missing/completed result still follows normal cleanup. A pending read or snapshot restoration cannot erase a newer error/result. Restoring a validated existing run is distinct from a new-start event in the actual home navigation listener, not only in the navigation service. These are the accepted read-failure follow-up semantics; focused and complete-source execution results remain separately recorded. + +### Authenticated restore of historical records with removed zone names + +A bounded schema-valid zone name missing from current rule data does not require rewriting a certainly historical or completed commitment's original civil fields, selected offset or facts. Preserve it with an explicit historical-zone uncertainty explanation; preserve a null historical offset as uncertainty too. This preservation grants no new start or alarm authority. An actual durable started/frozen Preparation Run is identified as a protected run, not mislabeled as a completed historical event; access/completion and restore confirmation remain subject to their existing runtime authority. A frozen flag alone cannot classify a future unstarted record as historical or waive the unresolved-future activation requirement. Future unstarted unknown-zone commitments still require explicit known-zone correction before verified preview/apply. Malformed scalar types, dates, offsets, IDs and graph ownership remain hard failures. This decision is for the staged repair implementation under construction and does not claim completed validation. + +The portable representation does not persist live `isStarted`; its decoder restores that flag as false. Therefore portable preservation relies on the existing strictly validated `startedAt` plus frozen-preparation pair as a **protected start fact**, not a claim of an active runtime. Do not require a non-persisted live flag to preserve that fact. Actual runtime resume authority is established separately after restore through explicit confirmation and matching validation. The future frozen-only case still has no exemption. This corrects the initial live-store wording for the portable boundary. diff --git a/docs/adr/0023-preserve-local-data-when-migration-fails.md b/docs/adr/0023-preserve-local-data-when-migration-fails.md index 0fff785a..e8293431 100644 --- a/docs/adr/0023-preserve-local-data-when-migration-fails.md +++ b/docs/adr/0023-preserve-local-data-when-migration-fails.md @@ -5,3 +5,18 @@ status: accepted # Preserve local data when migration fails Local Data Store schema migrations will be atomic and will never fall back to silently deleting or recreating the database. If OnTime cannot open the encrypted store or complete a migration, it will preserve both the database and Installation Data Key and start in a restricted Recovery Mode rather than creating an empty Local Profile. Recovery Mode will not start normal product screens or background Schedule Notification processing and will offer only a startup retry, an OnTime Backup restore, or an explicit Local Data Reset. The preserved store and key may be removed only after a replacement restore succeeds or the user confirms Local Data Reset. Automatic destructive recovery was rejected because Local-only OnTime has no server from which lost records can be downloaded again. This requires a recovery startup path and transactional migration tests but makes upgrade failure non-destructive and user-controlled. + + +Initial creation is distinct from opening an existing Local Data Store. A missing cutover marker, missing key, empty database file, or surviving SQLite sidecar does not prove a fresh installation. Only authoritative absence of prior store/key/creation or activation evidence permits a small, versioned, app-owned creation receipt to be recorded and read back before the device-bound key and encrypted store are created. The receipt contains ownership and stage metadata, never key material or user content; creation resumes only with the same verified key and owned store. Existing data with a missing, invalid, or unreadable key remains in Recovery Mode. The receipt is excluded from platform backup and is included in explicit reset cleanup. This bounded ownership record is preferred over requiring manual destructive recovery after every interrupted first launch, but is not a general transaction journal or a claim of power-loss durability. Damaged-store pair activation remains governed by ADR0036. + + +Migration atomicity includes the schema version, not only callback-local DDL. D04's implementation review confirmed that the locked Drift engine writes user_version after the application's migration and before-open callbacks. Creation and supported upgrades therefore commit DDL, backfill, required validation and the new user_version in one SQLite transaction. A later duplicate version write or callback failure is an uncertain response, not proof of rollback: an independent reopen must establish a complete predecessor or complete current schema/data/version before retry proceeds. Connection configuration such as foreign-key enforcement remains separate where SQLite does not apply it inside a transaction. This approach avoids a Drift fork or generic executor framework while closing the observed DDL/version gap. + +Migration permission follows the file's role. The normal legacy startup supports the recorded historical predecessors; an activated recovery pair supports only schema lineage that actually existed for pairs, starting with their introduction at schema4. Candidate creation requires its existing explicit ownership grant, and immutable candidate verification never gains implicit migration permission. The read-only original-store availability probe may recognize a supported historical legacy store and return control to normal startup, but it does not migrate, create or repair that store. Current version and supported-role definitions are shared so a future version bump cannot silently strand a legitimate older active pair behind an unrelated magic constant. + +Before migration writes, an existing store must match an independently sourced historical schema contract for app-owned tables, columns, keys, defaults, constraints, indexes and triggers. Missing, unexpected or changed app schema is preserved rather than normalized by destructive repair or IF NOT EXISTS. SQLite-internal metadata and semantically irrelevant SQL formatting are not treated as app schema changes; uncertain semantics remain a preservation failure. Historical fixtures record source/tool/engine provenance separately from mobile-binary evidence. These boundaries preserve user data and reject ambiguous lineage at the cost of deliberately refusing undocumented schema variants. The original D04 issue retains its initial questions and answers; the additional implementation questions, answers and corrections are recorded in the local D04 implementation review, with remote publication tracked separately. + + +Tracked historical schema snapshots are delivered to the runtime as compile-time constants generated by a small local build_runner builder. Generated Dart remains ignored and is regenerated by the existing build command; snapshots and provenance remain the independently reviewed inputs. This avoids coupling pre-DI database opening to Flutter bindings, rootBundle or a working-directory file path. The builder does not regenerate historical truth from the current model. A separate schema gate checks supported/current versions, provenance and input digests, including stale or missing generated output. Existing locked build tooling is used without a runtime dependency or SDK upgrade. This delivery trade-off was resolved in D04 implementation Q4. + +Native migration verification uses dedicated QA bundles, synthetic stores and separately owned simulator/emulator state. D04 implementation Q5 permits the Android QA project to adopt the product's pinned Gradle, Android Gradle Plugin and Kotlin versions, with required build artifacts fetched into its temporary Gradle cache. The installed SDK and system image, production build settings and existing device/app data remain outside this change. Verification records the actual toolchain, adapted QA sources, product inputs, generated contracts and fixture assets consumed by each build. Ordinary SQLite rejection must include a successful ordinary-engine control and an actual attempt to read the encrypted file; inability to load a private system library alone is insufficient evidence. Dedicated-bundle success and complete product startup, physical-device behavior and power-loss durability are reported separately. diff --git a/docs/adr/0028-require-a-verified-restore-preview.md b/docs/adr/0028-require-a-verified-restore-preview.md index 9182b176..9df67fc8 100644 --- a/docs/adr/0028-require-a-verified-restore-preview.md +++ b/docs/adr/0028-require-a-verified-restore-preview.md @@ -5,3 +5,55 @@ status: accepted # Require a verified restore preview Backup Restore will not replace active data immediately after file selection or password entry. OnTime will first decrypt the complete backup, authenticate its integrity, check its Backup Format Version, migrate it into a staging Local Data Store, and validate all represented data. Only then will it show a Restore Preview containing the Backup Cutoff, source app version, data categories and counts, and an explicit warning that the active Local Data Store will be replaced. Replacement requires a separate final user confirmation; cancellation removes staging data and leaves active data untouched. Preview metadata will remain inside the encrypted payload, while the plaintext envelope header will contain only the format identification and cryptographic parameters needed to derive a key and decrypt it. Immediate restore and plaintext descriptive metadata were rejected because they create avoidable overwrite and privacy risks. + +D05's streaming implementation review permits provisional records in a separately owned, temporary encrypted ingestion store before final authentication. That store needs no active or original database and grants neither preview nor activation authority. Only final authentication, EOF and complete bounded data and relationship validation permit batch materialization into the existing normal-restore or recovery candidate. Recovery pair records and durable key slots keep their existing post-authentication lifecycle. This adds encrypted disk I/O and brief overlap between staging stores, but avoids whole-payload memory buffering and expanding the established recovery activation protocol to unauthenticated input. Transient ingestion tables are not part of the active or portable schema. Its key remains memory-only; cancellation, failure and abandoned-file cleanup retain the existing ownership and confirmed-cleanup requirements. Plaintext temporary files remain prohibited. + +D05 implementation question 5 distinguishes structural validity from historical time-zone reproducibility. Portable formats 1 and 2 do not identify the producing time-zone database. The recurrence engine counts an ordinal after gap and preparation-start filtering; excluded occurrences still consume their original ordinal. Recomputing every historical ordinal with the current database could therefore reject legitimate history. Strict civil calendar and rule membership, segment generation bounds with the constrained retained-reference exception below, count bounds, slot/ordinal uniqueness and ordering across represented schedules and exclusions, definition ownership, references and override consistency remain mandatory. An ordinal cannot exceed the number of calendar-valid rule candidates up to that slot, irrespective of time-zone filtering. Validation traverses only as far as the maximum referenced slot and charges its work to the shared budget. + +For otherwise structurally valid past or completed occurrences, disagreement attributable to the missing historical time-zone provenance preserves the original values and produces an explicit, bounded uncertainty report in the preview. This is not proof of an exact historical ordinal reconstruction. For future occurrences, such disagreement blocks readiness and activation until an explicit staging-only review and complete revalidation; it never silently changes ordinals, exclusions or historical records. The A10 review path remains required work, not an assumed capability of the validator. A past null offset remains null: where the supported civil carrier plus or minus 24 hours is entirely before the fixed validation instant, a current gap or overlap does not turn that history into a future choice. A range straddling the present requires explicit review rather than an invented instant. This preserves portable historical data without relaxing provable structural invariants or granting uncertain future schedules activation authority. + +### Legacy offsetless metadata and completion instants (D05 implementation Q6) + +The old exporter emitted `cutoff` and `finishedAt` using `toIso8601String()` without forcing UTC, while `startedAt` explicitly used UTC. Some legitimate legacy files therefore omit the time-zone offset. Interpreting those strings in the restoring device's zone invents provenance and can normalize a DST gap. Schema 4 stores completion/start instants as epoch seconds and cannot retain an offsetless civil value plus its uncertainty without a further schema decision. + +An offsetless cutoff is accepted only as strictly validated literal metadata with an explicit uncertainty flag. Its UTC carrier is not an instant: the preview must not apply `toLocal()`, and freshness/reminder/history classification must not compare it as a known timestamp. If the restored installation needs local durable-data markers in this case, it uses the actual restoration operation's current timestamp, without claiming that the backup was created at that time. Explicit-offset cutoff values retain their normal validated semantics. + +Offsetless non-null `startedAt`/`finishedAt` blocks ready/apply as unsupported representation, preserving the source and active database. The app does not substitute the device zone, schedule zone or present-day offset. Explicit UTC/offset inputs must satisfy strict date, UTC year and existing database precision boundaries. New exports always emit explicit UTC for these instants and cutoff. A10 must provide explicit staging-only user resolution followed by complete revalidation; until connected, this is an acknowledged legacy compatibility limitation rather than a completed review flow. No new payload or schema is introduced by this decision. + + +### Preserve civil fields at the backup boundary without changing runtime semantics (D05 implementation Q7) + +A provisional implementation changed the global database civil converter to return UTC DateTime carriers, preserving foreign-zone wall-clock components across device DST gaps. Existing consumers still treat that value as an instant, including null-offset conversion, countdowns, upcoming selection and adjacent/date-range comparisons. Changing only DAO expectations would conceal a product regression: a Seoul 09:00 civil reading returned as 09:00Z would be interpreted nine hours later by those consumers. + +D05 restores the previous global converter and existing runtime expectations. Export snapshots, ingestion validation, candidate materialization and normal/recovery raw row copying preserve and validate the raw SQL civil text without a local DateTime normalization round trip. Verification must cover raw persisted values and portable re-export components on a device zone whose DST gap differs from the schedule zone. Earlier typed UTC-carrier readback evidence remains an intermediate result of the superseded implementation, not final D05 proof. A10 owns an explicit civil value/instant boundary and coordinated correction of all consumers with Seoul/New York regressions. D05 does not claim to fix existing runtime DST normalization. This narrower boundary prevents the bounded-backup work from introducing a new nine-hour runtime shift while retaining the evidence needed for the subsequent domain-wide correction. + + +### Authenticated time review and retained references (A10 implementation Q1–Q4) + +An authenticated ingestion store that passed final authentication, EOF, strict JSON and resource admission may enter a separate time-review state before complete semantic validation. It retains the existing resource lease and grants no verified-preview, AppDatabase, active-pair or activation authority. Editing is limited to version-bound, explicitly identified time fields: a bounded schema-valid unknown zone, civil/occurrence choices, and strictly valid offsetless start, finish or template instants. The original literal remains in encrypted staging. Cutoff uncertainty stays metadata and does not require inventing a creation instant. Authentication, JSON, resource, ID, graph and ownership failures cannot be reclassified as repairable time choices. Independent structural checks run where possible, and the UI states when complete validation is still pending. Each edit invalidates the old preview; complete relationship, resource and time validation must precede a new preview. Process death loses the memory-only key and requires owned-file cleanup and source reselection, not a new durable repair journal. + +The earlier blanket requirement that every stored recurrence reference lie before `beforeSlot` conflicts with the existing following-edit/delete workflow. Source review found that `_closeFollowing` closes generation at the anchor while protected schedules and exclusion tombstones can remain attached to the old segment. This finding is a source-based contract correction; an actual workflow-to-backup failing regression is required before claiming the defect reproduced or fixed. `beforeSlot` is the exclusive bound for additional generation. A closed segment may retain references at or beyond it only for evidenced protected/history schedules or exclusion tombstones. Original `fromSlot`, calendar/rule membership, count and calendar-ordinal upper bounds, combined slot/ordinal uniqueness and ordering, definition ownership, references and override consistency remain mandatory. An ordinary unstarted, unfrozen, incomplete future schedule does not qualify. These retained references grant no new materialization, start, reactivation or notification authority. Missing producing-rule provenance permits an explicit historical uncertainty report, never silent renumbering. Portable formats cannot prove the reference existed before closure; the limited semantics do not pretend otherwise. + +The checked current and historical start writers store `startedAt` and the frozen flag together. No legitimate producer of a frozen-only portable row was found in that investigation. The existing portable invariant is therefore retained rather than relaxed for an invented compatibility case. An ambiguous active-store row is preserved with an explanation and no automatic execution; it is not deleted to make validation pass. + +When future recurrence rules or ordinals need correction, a confirmed bounded following-edit plan closes the old generation interval and creates a service-owned new segment in the same series. Protected/history data and original tombstones retain their meaning. Existing materialized schedule IDs remain stable, and the proposed segment/slot/ordinal mapping is shown before confirmation. Matching overrides and exclusions carry forward; unmatched overrides require an explicit standalone-preservation decision, and unmatched exclusions remain represented with their lost applicability disclosed rather than silently resurrecting a deleted occurrence. A remaining finite count is inherited only when its derivation is provable; otherwise the user chooses a new remaining count or end. Arbitrary ID/ordinal/JSON editing is prohibited. Candidate changes are atomic and followed by complete revalidation within the existing work budget, without unbounded recurrence expansion or calling an active-store repository from staging. + +Each validation pass has a fixed UTC instant. A candidate edit, active-store baseline change, loaded-rule identity change, or crossing a relevant time predicate boundary invalidates its preview and requires a complete new validation pass. A clock moving backwards before the validation instant also invalidates that authority. Final claim rechecks those conditions before replacement. A device-zone display change alone is not a durable interpretation change. This is not an arbitrary TTL or a request to revalidate every tick. These decisions are accepted requirements; the editing UI, common consumer gates and regression evidence remain A10 implementation work. + +### Deterministic mapping of already materialized future schedules (A10 follow-up) + +The confirmed following plan reserves matching original civil dates for overrides, exclusions and protected references before mapping ordinary unprotected materialized rows. It maps the remaining rows in original slot/ordinal order, with schedule ID as the deterministic final tie-breaker, to the remaining available new slots in order. The preview exposes every changed segment/slot/ordinal association while retaining schedule IDs. Protected references remain attached to the old segment with their recorded history unchanged; the new plan must not leave ordinary unprotected future rows in its closed tail. + +If a changed rule, count or end leaves too few slots, the plan preserves unmatched ordinary rows and overrides as standalone schedules only after explicit confirmation. Their IDs, content and preparation ownership remain intact. This correction flow does not add a deletion option. Unmatched exclusion tombstones retain their original record and disclose that they no longer apply to the new rule; they are not silently recreated. The new segment count describes its slots, including the existing consumption by matching exclusions, while detached standalone schedules are counted separately. A provable inherited remaining count and an explicitly changed count are distinct; missing provenance still requires an explicit remaining count or end. + +A time override consists of the civil fields, named schedule zone and selected offset together. Following edits cannot preserve only civil/offset while replacing that override's zone with the new segment default. These accepted details supplement the original Q2 decision. Their actual question and answer are recorded in `plans/audit-2026-09-23/a10-implementation-followups.md`; this section does not claim the new plan or its tests are complete. + +The automatic remaining-count proposal is limited to a single open finite segment whose anchor ordinal passes the stored calendar/count/combined-reference invariants and matches a bounded current-rule scan with the same generation start, preparation threshold and lead. Its value is `old count - anchor ordinal + 1`, including the anchor. That is a calculation from the preserved count/ordinal contract and current anchor agreement, not proof of the missing historical time-zone provenance. Unknown or skipped anchors, changed future ordinals, several open following segments or unavailable evidence require explicit count/end selection. A resource-budget failure cannot be bypassed by entering a count. + +The correction plan reuses the anchor's existing series-owned preparation definition. Existing valid occurrence-owned preparation overrides retain their IDs, owners and contents. A future row using a different series-owned definition is included in the explicit standalone-preservation review rather than silently cloning or changing ownership to fit the new segment. This avoids relaxing the portable preparation-ownership invariant or introducing multiple artificial segments. The standalone reference must still pass complete portable validation; the decision is not authority to waive that check. + +### Removed time-zone rules on historical recurrence segments + +A schema-valid unknown zone on an original recurrence prototype/segment may be preserved without rewriting its rule, boundaries, slot identities, ordinals, preparation ownership or tombstones only when conservative civil/allowed-offset bounds and explicit boundaries prove that the segment cannot generate a future occurrence at the fixed validation time. A closed segment whose `beforeSlot` is still in the future is a counterexample: being closed alone is insufficient. A count alone also does not prove that no future occurrence remains. Open or potentially future unknown rules require explicit known-zone time review and a bounded future plan followed by full validation, even when no future rows have been materialized. + +Preservation still checks strict calendar and zone-independent rule membership, count bounds, combined slot/ordinal uniqueness and ordering, IDs/ownership, and the existing explicit retained-reference exception. Exact historical ordinal reproduction that depends on unavailable zone rules is disclosed as uncertain historical recurrence provenance, never as a newly verified rule. It grants no new materialization, start or alarm authority. A separately overridden future unstarted row remains subject to its own future-time checks; it cannot inherit a historical-segment exemption. Malformed graphs, exhausted budgets or absent proof remain failures. This is an accepted implementation decision; staged repair verification remains pending. diff --git a/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md b/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md index 758cdfdb..868cf331 100644 --- a/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md +++ b/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md @@ -5,3 +5,7 @@ status: accepted # Use Argon2id and authenticated stream encryption OnTime Backup Format v1 will derive a 256-bit file key from the normalized Backup Password UTF-8 bytes defined by ADR-0032 using Argon2id 1.3 with a fresh 128-bit random salt, a 64 MiB memory limit, and three operations, following the memory-constrained recommendation in [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106.html). It will encrypt the platform-neutral payload in bounded chunks using libsodium's [XChaCha20-Poly1305 secretstream](https://libsodium.gitbook.io/doc/secret-key_cryptography/secretstream), require an authenticated final tag, and reject missing, reordered, altered, or trailing chunks. The plaintext envelope will contain only a magic value, Backup Format Version, cryptographic-suite identifier, bounded KDF parameters, salt, and secretstream header, and those values will be bound to the ciphertext as authenticated data. Restore will validate algorithm identifiers and safe parameter limits before attacker-controlled allocation, will never downgrade to an unrecognized or weaker suite, and will use published test vectors across Android and iOS. Suite identifiers and stored parameters permit a future Backup Format Version to introduce stronger algorithms while retaining explicit readers for released formats. Custom cryptographic primitives, unauthenticated encryption, and whole-file buffering were rejected in favor of a reviewed library, authenticated truncation detection, and bounded memory use. + +D05 preserves crypto version 1's frame count before the encrypted frames by traversing one immutable encrypted snapshot twice with the same bounded encoder. The first traversal validates data and computes exact UTF-8 length; the second encrypts fixed-size chunks with an explicit final tag. Incremental length and digest comparisons detect a mismatch before external export. This trades a second encoding pass for retaining format compatibility without plaintext spooling or whole-payload buffering. Native export receives bounded, sequentially acknowledged ciphertext chunks through an opaque attempt handle and owns the protected temporary file. A seal verifies independently read-back file length and digest before opening the picker. Crypto frame boundaries and transport chunk boundaries are distinct. Existing save, cancellation, uncertain-response and cleanup semantics remain authoritative; successful sealing alone neither proves external saving nor updates backup freshness. + +D05's locked-dependency review found that the Android picker buffers and transfers the entire selected file before Dart receives it. Both restore entry points therefore use a narrow native import port that retains the selected URI or security-scoped URL and returns an opaque handle. Sequential reads are bounded at the native and Dart boundaries; optional provider length is only an admission hint. This requires explicit permission, coordination, cancellation and handle lifetimes, but avoids leaving an unbounded allocation ahead of the streaming parser. External source files are read only and never deleted, and access is not persisted for future attempts. Android's [document access model](https://developer.android.com/training/data-storage/shared/documents-files) and Apple's [open-versus-import and coordinated-access requirements](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/DocumentPickerProgrammingGuide/AccessingDocuments/AccessingDocuments.html) inform this boundary; the Apple source is an archived guide, so current SDK compilation and actual provider execution remain separate validation requirements. Provider-internal downloads are outside the claimed application memory bound. diff --git a/docs/adr/0036-select-recovery-store-and-key-as-one-active-pair.md b/docs/adr/0036-select-recovery-store-and-key-as-one-active-pair.md index a856ce34..41117e2c 100644 --- a/docs/adr/0036-select-recovery-store-and-key-as-one-active-pair.md +++ b/docs/adr/0036-select-recovery-store-and-key-as-one-active-pair.md @@ -11,3 +11,18 @@ The active store epoch identifies the selected pair, while the runtime generatio Old material remains until the new pair has been reopened and verified after restart, then pending cleanup removes it. A committed restore is not automatically rolled back because cleanup failed. Missing or corrupt manifests with prior-manifest or in-progress evidence enter Recovery Mode instead of falling back to the legacy pair or creating an empty store. Platform flush/rename behavior and failure recovery require implementation tests; this decision does not assert power-loss durability or report D02 as implemented. This deliberately adds bounded inactive key material and an installation-local pointer instead of overwriting the sole fixed key slot. There remains exactly one active Installation Data Key. The decision and rejected alternative were resolved in the actual D02 grill transcript in `plans/audit-2026-09-23/issues/D02.md`; file/key ownership, reset cleanup, preview currentness, and mobile acceptance remain part of that issue. + + +Restart read-back requires a new native application process, not another service instance, Dart isolate, or dependency graph in the committing process. A process-lifetime opaque nonce obtained through the existing platform channel distinguishes the committing process from the subsequent startup without introducing a device or user identifier. An unavailable identity keeps recovery pending. Activation therefore reports applied data with restart and cleanup still required; OnTime does not force termination or promise an OS relaunch. Only authoritative manifest/journal resolution and encrypted-store validation in the new process permit retired-pair cleanup and normal startup. Host-injected identities, child-process tests, and physical-device restart evidence remain distinct. + +The pair manifest and bounded activation/inventory record have a dedicated file adapter. The manifest selects the active pair; the record owns intent, candidate/retired material and incomplete work. Delivery ownership stays in the existing alarm journal, and the existing operation coordinator, data gate and reset protocol retain their responsibilities. Reservation read-back precedes key-slot creation. At most one unconfirmed candidate is allowed, and uncertain cleanup blocks further candidate creation. Explicit reset preserves its slot inventory through file deletion until key deletion is confirmed, then removes the remaining metadata. This separation avoids coupling storage recovery metadata to notification schemas or losing key cleanup authority during reset. + +Explicit recovery also distinguishes pair authority from legacy cutover evidence. Reset intent is resolved first, then pair authority. Only a legacy selection enters the fixed-store initial-creation/cutover path. For an explicitly restored active pair, a missing or invalid cutover marker can be repaired only after the current startup has actually validated its encryption, schema and runtime state, followed by allowlisted legacy cleanup and marker read-back. This does not authorize clearing current local preferences or deleting the preserved original pair. A versioned pair-use marker in device-bound secure storage is confirmed before first activation; any surviving pair-use, owned-file, journal or temporary-record evidence prevents absent metadata from being interpreted as an implicit fresh installation. Inaccessible storage remains unknown and pending, never absent. + +A narrowly bounded interrupted-first-publication recovery is allowed when manifest absence is authoritative, exactly one supported confirmed intent and consistent inventory remain, a new native process has revalidated the same target pair and the original observed identity, and no conflicting staged or later activation evidence exists. An explicit recovery action may republish only that intent's identical target descriptor and epoch. It neither imports the backup again nor rewrites the target DB, regenerates a key, allocates an epoch or deletes the original. This covers a crash after the pair-use marker but before manifest publication, and a lost manifest only where the same lineage remains provable. Corrupt or unsupported metadata, I/O uncertainty, a different descriptor or a conflicting later generation remain preservation states. The operation owner persists and the outcome remains undetermined until authoritative read-back confirms selection. This is an explicit same-intent exception to read-only authority retry, not a general fallback to a guessed active store. + +When activation stops at a confirmed intent and authoritative read-back still selects the original pair, read-only retry alone can leave recovery permanently pending. The user may explicitly stop that same unapplied recovery instead of broadening the publication exception to replace an existing manifest. This requires intact single-intent lineage, unchanged original file/key identity, no evidence that the target was activated or that a later generation exists, and no conflicting pending metadata. A durable cancellation-in-progress record must precede deletion of only the owned target and its exact unpublished manifest bytes. Inventory and the gate remain until file/key absence is verified; a completed-cancelled record cannot conceal unfinished cleanup. Interrupted cancellation resumes the same cleanup. The original manifest and pair remain untouched. Only confirmed cancellation permits a new preview and confirmation; it does not prove that the damaged original can enter the normal app. Unknown authority or any evidence of target activation remains preservation mode, never rollback. This trade-off was resolved in implementation Q6 after reproducing the known-original retry dead end. + +Verification before the first ready stage binds the originally confirmed candidate runtime identity and requires a new native process. After ready, the manifest continues to select the file/key pair, while normal A09 in-place restore may legitimately rotate the database runtime identity. Later startup therefore validates the existing selected encrypted file, supported schema, integrity/foreign keys and current local-profile runtime invariants, rather than requiring permanent equality with the initial candidate identity. A currently pending normal restore finishes through A09 cleanup before cutover repair and app entry. Missing, empty, linked or unreadable selected files never gain creation authority from a ready record or surviving key. Only the owned reserved-candidate path may create a recovery database. This preserves the distinction between store epoch and database runtime generation while requiring current evidence before marker repair. + +An explicit first-publication retry may also finish a failed pair-use marker stage, but only for a legacy-original intent still at confirmed, with no activated or later-generation evidence. After all same-intent/new-process/current-target/original-identity checks and authoritative marker absence, the same version marker is written and read back before publishing the existing target. An acknowledged exact marker value can resolve a lost write response; a missing, invalid, unsupported or unreadable receipt never authorizes publication. This exception does not recreate history for a nonlegacy original or a previously activated pair. Automatic startup remains read-only with respect to manifest publication, and the operation stays owned through unresolved Futures. This bounded Q8 retry avoids a permanent wait after the first marker write fails without expanding Q5 into a general metadata repair path. diff --git a/docs/adr/0037-bind-schedule-save-receipts-to-local-aggregate-identities.md b/docs/adr/0037-bind-schedule-save-receipts-to-local-aggregate-identities.md index 261e930d..f0474ac1 100644 --- a/docs/adr/0037-bind-schedule-save-receipts-to-local-aggregate-identities.md +++ b/docs/adr/0037-bind-schedule-save-receipts-to-local-aggregate-identities.md @@ -5,3 +5,5 @@ status: accepted # Bind schedule saves to local aggregate identities Schedule saving uses one atomic schedule/place/preparation/revision transaction and installation-local store/aggregate identities, versions, and the last mutation receipt in schema 3, rather than a global mutation ledger. New schedule drafts additionally fence an absent target with the reviewed durable revision; this deliberately requires explicit re-review after unrelated edits so an old create cannot recreate a deleted schedule without retaining tombstones, while existing-schedule edits compare only their dependent aggregate versions. Portable backup versions 1 and 2 exclude these identities and receipts: restore/reset must issue a fresh store identity and fresh aggregate identities even if user IDs and revisions match; recurring split receipts remain on the stable series root, and mere occurrence materialization does not advance its edit version. + +Default Preparation and spare time editing use the same store identity and a coherent durable revision baseline without adding a preference version column. Both values commit atomically with one durable revision; unchanged values cause no writes or dependency invalidation. Since this pair has no separate aggregate version, unrelated durable edits deliberately require an explicit reload and review. A stale draft is never silently rebased, even if its values happen to equal the latest settings. Cache refresh and current-data notification reconciliation are separate postcommit receipts; retries never repeat the preference writes. Leaving the screen does not undo a commit. Existing profile observation/startup loading and alarm reconciliation remain recovery paths; these in-memory follow-up receipts are not a new durable journal. diff --git a/docs/adr/0038-stage-restores-and-fence-runtime-by-store-identity.md b/docs/adr/0038-stage-restores-and-fence-runtime-by-store-identity.md new file mode 100644 index 00000000..9c816dbc --- /dev/null +++ b/docs/adr/0038-stage-restores-and-fence-runtime-by-store-identity.md @@ -0,0 +1,31 @@ +# ADR 0038: Stage restores and fence runtime by durable store identity + +- Status: Accepted for A09 (#602), with startup recovery integration tracked in D01 +- Date: 2026-09-24 +- Related: ADR 0011 (local only), ADR 0037 (aggregate identity), A15 (actual delivery ownership), D03 (reset journal), C01 (commit/follow-up receipts) + +## Context + +A valid encrypted backup used to be parsed in memory and written directly into the live store. Restoring the same schedule ID could retain its old early-start preference. A process generation alone also cannot reject an old OS notification after a restart. The old exporter did not preserve first `startedAt` or the preparation freeze flag despite the durable-record intent; those values cannot be reconstructed from old backups. + +## Decision + +Before offering a preview, materialize the parsed backup into a separate SQLCipher database in the app-owned application-support staging directory. Use a random filename and a memory-only random key. Require a nonempty `cipher_version`; plain SQLite is never a production fallback. Require native backup exclusion, foreign-key/integrity checks, and canonical durable-value read-back equality. Exclusion failure fails preview. Candidate cancellation, validation failure and successful replacement release the staging SQLite family. Cleanup never follows links outside this namespace and excludes currently owned candidates. A process restart can remove abandoned ciphertext without its lost key. + +Schema 4 adds `Users.restoreCleanupPending`, `Users.rejectLegacyDelivery` and `Schedules.requiresStartConfirmation`. In the live replacement transaction, replace the local profile so the existing schema-3 store-incarnation trigger assigns a fresh identity, mark cleanup pending and reject legacy delivery. These are installation-local fields; portable backups never choose them. Profile updates and backup metadata updates do not clear the markers. Replacement identity remains distinct from content revision and aggregate mutation receipts. + +Runtime preference payloads and minimum notification payload v10 carry the issuing store identity. The receiving process must not attach its current identity to an old callback. Legacy payloads remain accepted before the first restore; after any restore they are permanently rejected, including after restart and later restores. Old OS deliveries may therefore be harmlessly discarded. Android/iOS sanitizers preserve only a valid originally issued opaque identity. Native Android full-screen/boot scheduling remains policy guarded; the active fallback notification path uses the same Dart identity contract. + +Existing operation owners await actual old runtime/provider futures before replacement. The existing data gate fences ordinary writes by their operation generation, including transaction checks and stale cache emissions. No second mutation queue is introduced. Ordinary edits remain permitted during the export picker. Restored unfinished rows require explicit start confirmation: a preference-only marker could disappear during cleanup before a crash and allow past-due automatic elapsed-time synthesis. Only the existing explicit start transaction clears the durable flag. It preserves the first historical `startedAt`, frozen preparation and recorded score/outcome; the new runtime start time is a separate event. Future occurrences first materialized after restore use ordinary new-occurrence policy. + +Startup performs D03 reset recovery and cutover before opening the store, then removes abandoned staging, loads the durable identity and retries known pending cleanup before DI. Live and startup cleanup share the existing ownership journal and platform-delivery/launch cleanup. They do not run reset, DB deletion, key replacement or blanket journal deletion. Unknown ownership is resolved only by authoritative global absence; when Android cannot enumerate all alarms, verified individual cancellations plus no unknown ownership are required. Runtime preference removal false receipts, exceptions, read-back mismatch, platform uncertainty and marker-commit failure leave the durable marker pending. + +The portable payload remains format 2 with optional `startedAt` and `preparationFrozen`. The new first-start instant exports with an explicit UTC suffix; schedule civil time is unchanged. Existing finishedAt offsetless history remains a separate timestamp-policy limitation. Missing fields in historical v1/v2 mean null/false; active `isStarted` always resets false. New readers preserve the fields, but older readers may silently lose these optional fields. Unsupported future versions fail before mutation. Explicit frozen preparation without a first start is rejected because the actual freeze writer records both atomically. No new wall-clock ordering invariant is imposed: a completion earlier than first start can reflect clock changes and remains preserved without generating outcomes. D07's narrow prerequisite adds restore-only template timestamp preservation and UTC export; nonzero subsecond template instants are rejected instead of silently rounded by the seconds-based DB. Offsetless legacy input retains its historical local-time interpretation; no original timezone is invented. + +## Failure ownership and limits + +A precommit staging cleanup failure is a typed `RestoreStagingCleanupFailure` that retains the original validation cause and cleanup cause for controlled diagnostics. It does not represent a committed replacement or notification retry. The UI says data was not restored and startup will retry temporary-file cleanup. The same candidate may retry disposal. A postcommit staging cleanup failure keeps the durable pending receipt; follow-up retries cleanup without applying the backup again. + +Pre-DI store/key/migration failures retain `RestoreStoreUnavailable(cause)` separately from a known pending restore's `RestoreRecoveryRequired(cause)`. Causes are not rendered or logged raw. The current pre-DI surface is retry-only; integrating explicit reset access and the complete startup transaction is a required D01 follow-up, so A09 must not be described as end-to-end complete until that integration is closed. + +Host SQLCipher evidence verifies the actual encrypted staging pipeline and encrypted live file reopen with a correct synthetic key. It does not prove the Android/iOS linked runtime. D04 owns device artifact/runtime proof and historical plaintext-file risk. D05 owns broader backup graph/value/resource-limit validation. D07's complete timestamp policy remains separate. Damaged-file/key replacement is D02, outside normal content restore. diff --git a/docs/design/redesign-20260923/flutter/screenshots/u01-en-confirm-200.png b/docs/design/redesign-20260923/flutter/screenshots/u01-en-confirm-200.png new file mode 100644 index 0000000000000000000000000000000000000000..9a4a260dd54e64022fb40a84a396c7e8d894ad07 GIT binary patch literal 61008 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u01-en-partial-200.png b/docs/design/redesign-20260923/flutter/screenshots/u01-en-partial-200.png new file mode 100644 index 0000000000000000000000000000000000000000..0d816e704ef7999f4c5bfcac57fd4c7ecb6676b0 GIT binary patch literal 52792 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u01-ko-following-200.png b/docs/design/redesign-20260923/flutter/screenshots/u01-ko-following-200.png new file mode 100644 index 0000000000000000000000000000000000000000..5e49c1984b757d8e76449856cf22663b5bdd44a8 GIT binary patch literal 75225 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u01-ko-following-end-200.png b/docs/design/redesign-20260923/flutter/screenshots/u01-ko-following-end-200.png new file mode 100644 index 0000000000000000000000000000000000000000..73db28ecedda5ee39444dd05917013835bbf0809 GIT binary patch literal 88032 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ℑ%ng;4J<+!^_z^I&?&6j{7zD4}e+PrF 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u01-ko-partial-200.png b/docs/design/redesign-20260923/flutter/screenshots/u01-ko-partial-200.png new file mode 100644 index 0000000000000000000000000000000000000000..4f80e3dd480a73611a5e8f450e7c97d8540eb405 GIT binary patch literal 41506 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 DFKeHz literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-en-cleanup-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-en-cleanup-200.png new file mode 100644 index 0000000000000000000000000000000000000000..1d6918f7d93e2b16b19177666dc9168736390075 GIT binary patch literal 60722 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-en-delayed.png b/docs/design/redesign-20260923/flutter/screenshots/u03-en-delayed.png new file mode 100644 index 0000000000000000000000000000000000000000..54f2658af7cd62f80cf0313603519a76978dc98c GIT binary patch literal 46480 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 I6>990U`S=BrTS&EeHaHJl*ks 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-en-failed-on.png b/docs/design/redesign-20260923/flutter/screenshots/u03-en-failed-on.png new file mode 100644 index 0000000000000000000000000000000000000000..6aaa1cc9d607b3887de89cd659e3696900b681e2 GIT binary patch literal 52525 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-en-read-error-retry-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-en-read-error-retry-200.png new file mode 100644 index 0000000000000000000000000000000000000000..3d492528daf82d38a760acfc6f81c0f6f68c6e25 GIT binary patch literal 51870 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-en-saved-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-en-saved-200.png new file mode 100644 index 0000000000000000000000000000000000000000..67499a3002f67156ae39df75222d0e6e58f39d99 GIT binary patch literal 56407 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 Le}?JK-~2xSUujb0 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-ko-cleanup-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-ko-cleanup-200.png new file mode 100644 index 0000000000000000000000000000000000000000..47a8d0752f6e4eba8f3f135447069ea321f72bd2 GIT binary patch literal 55242 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 YWiItS7LJo1py02A`*qYaRV}anFDY!04gdfE literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-ko-cleanup-retry-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-ko-cleanup-retry-200.png new file mode 100644 index 0000000000000000000000000000000000000000..c434c8ac0f42ede7d75774deb28b2c9a77a26edd GIT binary patch literal 47055 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-ko-read-error-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-ko-read-error-200.png new file mode 100644 index 0000000000000000000000000000000000000000..0669d0f3613825dc6e9d428e54a4182ef98b0f74 GIT binary patch literal 52705 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/design/redesign-20260923/flutter/screenshots/u03-ko-saved-200.png b/docs/design/redesign-20260923/flutter/screenshots/u03-ko-saved-200.png new file mode 100644 index 0000000000000000000000000000000000000000..1f4f401627556ad9b5e3b3bd96af7b369647b5c8 GIT binary patch literal 49921 zcmeFZWmJ{h_doj3NGk|Pg8|YFA`PM+5fF~)sy&p2nCa}@V}_F8kz`Kh%-RUX|XCZHie5QO;tJ$W?*!LURS zEGB$h_!SbTgH!kywu|h24Se{^8~G(43~@b@Wu<=hU`*)_EQ#TsTFIjN{7wL zYy4DGEze`?e9s(JhIM$V>YOBvDT3uPw7(-RuKf1-MlCDGbn^3_=RFG&o{7vD%t2&5 z{ex~e_{>3U3K)rGvZAjs1(|~!DR3-h@#okG`hv(>RVmUhFb8F^Nt4Us-&OU$5k&ST z6M2ZwOn2x1{{#Q$SE+QBAWB5edE4bf&NVD#GO;ep*N3(^)<{h)yvcL}LAtxKEyp_5 z3m34YmAc*Tv9lW+TZUBIP1qDqj*d>Saoxl~K95Y(9vvO&aU8D4D4xaAnL}2aiEwd?+CL_qfLf?j? z!+Jm|RgcW0N5XZkYb8Sny_cm_&#N>XME*{PwUBg2+iAqYgCX|-&asoWu_S&Z=Gtup%HRcD`c>;%D1MsaM(y6cKW?0 zi*ZE1Jz`X|wNTWnyWw~J4^Mo2e4ck|$j%a(pkGd_9c>d~JKogu0)0bRPpxC&vMRYI z-@s&0%q#nlm#<9OaHzH?J5y+08XIv9$bFHw}+NJvtM& zcUECP?Ui1c`g=+SBhgaR!-JX<_lH)UPfuI`JKhSbGK0x}$y(7rdcJM--Bpngw=uOy zMpolpR=M}~q|;~guU`}J?(HO$-gMMz!erWDAn4o3vulxRZELGKT74|?tJfuWh+RNn zCtKgqu*&PI-t%f40)_68-Z)-=MQj^y?G8XWmW0 zo|0USlt%}9uToPb2L{#4?SBx6d+n0S`i=*eOnLX$xzTDZ&2M=e_};x&HL@|&^lEl( z1_LqgNsL_l@t93CRVbA0FxY+&~*X(tL11o!9*DHS_ddC-ZCB@mGZ|V)18FTr$zayr#%^^R++FhmqmS!u#x8B z66R^YrUz9%-gVy1m5It}lB)B@Q>(5#+_9dMH8bhfy3`Mo>gMO?(h!8@>Y4nrXTvt7 zS3O1GAm~NM#dZDWBc(ftZf&_EAb^2Obb^mK9uGeZ36c#qvnqTLSeztMiiN=9f9WqT zZ}s_Xrq{|NFd!smFc)KT?@~&Kl?p<7OQ_0UsWYUKydb8 z#%8?R!0PZvqL2kc7=e7zz z70oqiw8bm+vW15u@nO3Z+ZN; z$a9{Y1mz?C%gcrF7e&j5_n+9>c8mM!)-DFK;P4CZ;$Q|$3Ttj>_9mZYjoVPMeBt{$ zuUgc_v_WDm)AXQH@L;&Y%*J(hz57H*N3T`S-0n@i-+A`#=xV=ex9QlP_;0= z8M(Qy=B_GWA&*kT-?X;M1|MIS@M{;hH@0D|iLevyobR|WTIE8E%tGnR4*EMG{z8Ru ze1xhOS;8;{`w2`fMeJo`=N>|9Z}_AVwSbaRszY5sbD@ORF`uB!AQ{c|}3da=e{ zao-~2M0ZcmTdnMm8>^oY&;9<*Sz}O*Q*29DU6zg{H6HUQKQBAy^`_xX^=vf%;T(a- z_Uw!D;~>nK0~-Xr3UpcU=0!f^7$J9z1qbVWtElKQs*aE?e)*y9UV`CZaTU< zV9+Mvj#ip2H7$kIg}d3r%?}moRfv7N9ek8NbDjT3z3Nq5n+A{4>WvKdjYfP@(l5hY zv{5N3#EfYrh<~9r_gtKjFD^nvBTkW;YAB?!)8y6Yoe)qaa9(Y=adzsEy4Mx%TIs+l zB_YQDOIcpw0Lswhw+gY}s6#QLUIsorZ?zW47bOh-4Bn@2`EK3XW3 z+piI%^DEaWr=79;+_-gX4Bd`5XOskpk#BS$CXr6sb3dR&vRn(PM`B&H2D7n~uOu!ON*59w~wAsu+Cth~fK8@R; z-t;J(N7vny`pOlPiQx$Lp?c}>Zwm%m+6Zubw%}x36%xXc+Bn6C<1uPBOR!p1Yq>3} zp%-~LUd5qP`-D%RybYRAY_Sd@{AJGQ&;86Bc<{tElr;7Ip*f^A`TZN zm#5XarH9%lC+Up3lL@b0HKnGZY5n#M|K&>sUS3}RfDC3OB_$@ZcsVBCSNZuj;eTLy zv58RM%r3$qMidm<_@hkLZl zslJt;h?tnqw^id)olskA`k3Y4zp}dY*y+fry%m5Cj+(axTuY;^1DCMye1d#W@tAo; zsKg~0I5|0WXRPF_y(J`kyoGcAxz<{nvFYgEOFU!c)=svtv$PC>HO~5|ED|Em@;)}U z89oX;7Que$!}@(O<8~?+rs#XTPnW;ZG{qAU5%tx(8+mv(yecYE%H}wYb|=KKRI}LK z+cPmpGe}o;&r~$%8yr;6k14gyB#7+zlBX;gn2~XDwSq>l>`owVH9<|ox{zYXx+*0e zv)jFx*&;G7k)uah$ir#H*8_j?JqJ~&@KkjTK0LR2o~c+koGR)mb_r1_O}*UwD>dlP z69Lsbc9n@!w<9nGP1=gB1Q!22gfm-Z*eGd~_MNx9YUs1KmHDfpm_lLXQs|^SmEH34 z)D;E>t^l!5nwqP_cJaL_xFjJ57=kamH^0CkoLyRa)z_y|9HXnD5p;HX{H3>-v^P~^P_I@E2M1?!fB#ie zlhoU{Zz~NYnH?}2OCIU#>xV~5o$0LnlL^E%Y4*p67x%0f@S)_^59vv{UFd3MC+eP_9!EzX z-`>*Ro-@2n$*JS7p8Kd=tcC?%As{7%e)Tkx{`SkFBF^FCiLo)0&DoZ!_5J1ktGj=O z!rW>R$Ae9igM)*z>GRGQj?r>cyh5K>#I)_$ma-uvSd|G0S2DA*RNYd$gD#MhF*1@~ zy?XW2r%$uvRW?I!VyY7(A|h@L#MhJE@Z zDu3q=Gdnv$l8A$Xqa*Lh>8TCp$d4byhK7dh4KwhmuU)&A=NkTVeX9N<6;+3q9}f?Y z7RLC`pOxdsY&<*`Jqv&S5J2~-UDHhaxUjzdrn$M9X5Z=AGp#~f*=a2?!ak($lheF) zshPPsW(?P3AD>g}^UA}+!){Y}*laTkbMq$$n{!Gk$-;Itm{?e(-U&>1lTQt9yq_X##0s8qj%u&Cdhy zv>~DuR8~(*fHdbkOzLa&d0zFWXjxx#^*(-n!XqqK z&EVkR?(EMM-2Oj*YAlxL=jRVp0JV7u1%r=`d2%oxxjbG~*n0Zk9^s&0>x?s)t9(~O zgJQ8ajnv4Vm;^DO8Z)Q)13&Ol&Ow z7}xjj-<>C9<>aoR)g%v9YmdcUK05NJxMP zPylmN`&10)-_gy@ES2i=Wj<}0fB*hD7y1ZU4FqXQT=3Lhz9#K3UU}h3`KR$!Djq{@ zc9#z$rADQjqb^H57zjTMUrp1;9k6Vkv-1y-(OwZu?ifd%zjF& z(r$v%p#IsNCr?-;&re)zr|K^6t&V8dPTo|{G46bSvwO#)H~6(ZsM-6^v=b+osN$HBo7tV<va56Wq6pfBa*QL+9!H4 zXJ?JE@$kMtBea{S4$jWL4nGxnaNw~>ZDC>Yce0i|H8r)U$7!MSGN(?N^g>;g!;Ilt z@+|%BXG}M5UMMLk5p`WA!zG~$f#%6+)JT^kY^RoAgh$H2+u}J?pb6XIT19aSnu6=< zFfkqp9olku?ZySQ%yP4{=QiiQ2(4ee6v{+~KRY|?k4sF`{Qdg{I97UH4L5#2?8ujq zwR3TG{W|v1!s+iQU$Vq=O89Jd^z`(qDC$V)#nNGX#7p|rDaLKwzkeSVs;P62or6Q2 zLt4snJ7|T0fPmmdKmZ0MmtN4ts|vI5cGUb&@eAL}=EHf;`tSk!-HnF@PJeEt^`h&s zHD-}k&Ja&8?!h}=ZAU1XMv8@nwRz}W+W}LNS3n>THluZZXM0C?<+C59rNaUSJxLn_gK;Quth{fL~q^7PbS_*&$fo7Zko@(D~R#Gzhi)$ zi(7qYgse4eI(0SSKv4^r6Rl6yV!6A!CrkOIUa-%)8`%!!$xMb1XTp4UZ9;o}lv(cO zY#kF!v>eh@RSkfRk!Fcbhf_vSUyeT~_Hg5V-XNu`n%Y~1FcM-qQI!S6&dMtE4gKu` zpGOGvv)}=QgA5;97W&~1j*hHd8S?AhH=djvY$9)w_1d1o}I*l&?6f zwZ^=L#~n~cV1t5UIJAco6_(f5BCbD(Ei-)g;bD?6JJ-RrRXEU=!$k>t9hbIm%*#`~ z`uGu~mq=niKfhw-li32;*zei-#YRmGjNY38laluK_M%R|uyIKlh@$TWMMaT~j*mZS z55H2>3zRQ7Qt~JyA$|VOpVyK;N6kGwBwAWpd3kx#US48v-@S9%oRyjUnQ#%qi^6eXQ^8Ug?Lcyj*`Pxwf{psBbxc(CYfzpM@vvFdYsp(PW`BrfQw% z^w;PoC)487RXBLghZC;>`Vc;@wv~amvmw?YrWKaIcMl)x+FJjf;>DF;$5^;T{%L7O z5yyHuI>s$8aPTi&z+gZAt+}So8uW9$n4s_a_3PIAF6>tgR#`+usGvm_MTSL0n7v5l z&(6*+>Qob&mrDt~HsJQK#Hi^F08PE)QZv@BJb^D!WNNn`VJ}Zk>Q6R22lcJ3t&PCQ zVK66RQqa;4C=bF;wS9QHKU1Yw9TO9y)iwAvPc_SN`}h5)W@arN9e4uf-O|vXO>Ark z$;rv@UAy#qVSyQtO4Hd9$Dr3UH@7>lNa%IamY~XuhH*`f7w&wU2q(u7-qUD#!WA!Gybvt9 z$-F_5f@`U*txZHPrm;3pPfuT^msVq7SGauNf|xO$l7=AW3d<=zma&jVAmr=})A~5(Vn9MnkI*rPV zG2l1IXlOdE{gjoJ1?|QuqNAe~_iwVXNkcvL1N0O3J<$oP()>GI{1{Y)yVln1$Slx^ z&7B?7^~@43*uTKEG)7pF^YQiIPwpFr2Zx6y(AeG7&KiA=XE!z?p(HIE{)UpXwgPkU zJ3GuB?$T1=q|EX=KZ95K((rs=L-Gt>UZ=rJ=-K+DF6-XljYmaz#0Za3qrAR;^5j%O z4D?JnB_$$6`b$-6nbW+2yn@0LI1bbz_H+ok+kl0>b$35Ccw}hE;Oy*dY;KPG`0?Z5 z<7?NiXJlq#l8}%%yK~k)-{=1I>ld%b=B!??Iy5_0uKX5>t$XTqkZeZh(r3?|8`TaoBmvU? z`Rcg^nj0f`STXceyX5}~-I*9WDNpC`i;IgJsY`?LH6UH^26p7hRGr&0z}|mr6S&n2L>}?Kqc61L=pg&|E58c1a=lI#~`qblW{u-bGHX|jP&kgj>PoV%?)O$p|bwlG*;OyN*syE#IQA@;&`VmMv0L{A0%N{KucLN5i!)OYd>9G@m|w zYTO;qr{o{^IwV9|ULG3?;}u|#gX7~u4SQc-P=*q|SkC|;i?MI!pE62iii#596=Rewti>MQEzYowX_j@HWmyw>4thgC%BCE^d`v(A0v z4e(`V78XoEgQgvC+WIo&F8e;~wCB4Fn5ip{heN^v@U2U(gDy07ng+&vI0eRoIS;#c zDk}x$@7}!%<5<9=N25?FKpFv(SUUo=Fghv<(U`v0+S-~q&X?E;IN=qeZwRnmpj9Y# z0#FqMf;EuKS8EeB^RU49L_|S4oLVcA?=S<92XWlRPH6zG^Sy(w*=$TV92~8dE*L%l z_zC0)ZM;xn8imA(@W3W88WHr3y-&6}99>-G6clh(_qd^)g~V6guO*H=UP!p6n@1$$A%6PT@4tQQIz6viV!ay1ED%eRePQq)LP>FIyHpt|2)++C%x926xS!FiJ2NDkp77 z871XG6v03yY8>uDMF(Cm*U<10QgzZMlOhzlr2+=C_IsRt?kXp}b!SKI~7Z369@L-aZWXQzhdR_vi2D&iBeGCZGxfg_vP#9q#tx_HDZ)n%+R~vrzQ00=g z{L>mti1_zPo$GUpW;x4j0&_;CAV3P6o14aG_}S<)eE9IedSSRwn{@Bb5Ei>1E9_o7 z{NTb7dBf+=XfpN@STW2W6WvhnWov7jg1sCP0uE7g0M=#Sasa_75knU@f>tCdDXGi* zpBg|3XScBMaBnVmC8P2uI4YiI&t{u37GFc5VF4SBm-3T*^5hBG#ft%8nOJ!@{{H=R zvD0?zR~x~G)h#%!C`=*?mH$r}+bb3QxvXzu!i<)U#PfehjFBwBgI1U;ycRulK(p{* zbm5bbC`?q_3Hkbsf7p>}-Q0BiJ5^6rV?XsouMRXCl-mJh!<9@kT6-&U`*xDs;Xu7y z;d3c*&Fj~$8E^mouE$ZDmlyIqM`gI9dugXXSDEo^N5_}1Uq9$EF2Ovbz_rW=#QFs2 zq4dInE4;ArlP527mD51r04JdWC4Z|uIPJY z3=AX)NXDu9emiJa0c8M=1Rk8q;&Qrb`^ir&;*HPNXyAli)t-4=W6#$+<{EjEJ-;o{LE7ob^eVH%j>xJ zN3$5)(rPhSnn`7RDj(bwAj!-C4q$)q2?^C#=m55$!wtBKBQ(oJm%V@g6aYkmLd^@q z4nJ6y)1Zz5_!Bj+F@j>BREg)A@LXjzH6sTN){40pVxpot+*RLTTT#IAqP6&O4H+FB z3dG^aR`sA1B>knYj@?I)ZW52Go~@13L>d4p0WN^8G0k@jyXTCRDrBI0uO@%Ij*K8! z$&rMJ=qsR<-~39MnVG!4CmvtFer4k1B!U)Ss?`n?WNvmA(a2dIQV_naFuun$1oUHc zbd-sMgAh2d-0E;K1_D@Ilf#1`Shm)+1^5CpGjqVodS_3M)@`<~KLD&*u3mLIJMrYy zt&pD^YKM>S-`ND?IL>+k6ov2ly3#ogp8*26AzE&Zdoa7YO@XHdFA!hh#|D+tI=k{F z3;kD$P#&@MB; z%yXU+1bEqs_V}Sf?Rn_3X!*F{{9Ri!SbKfqHmK|WNKx_0iyh!3WE2!%PgK)DP=P-p z3vb zdDt2PtX)Z*;R2%3^++{tw`UV-@?gDw zqYMhl+7A!Z17p308SkA4eX>$6=?8(eFsi-w*CPOeWq{x8OdMITH&*My3N7FzG%w)M z3~*+ZhGV!_IHipmye{Yc?a34KngkuYJ4u8T9G(tX5I*}!Q9#D;$4V4^D{Kkp;FnO| z+j>gEARYqq1}i3CuMQOx7b^_^rDa3Wg}iyw4$@8khx_EUg8bz=;V`G>07OZfF|OvO z`F0*95BN!H_-bjYR{{%Wko3k!^azOthK*sqUbc6b{KN<6_GeFV%n`81_>0Fsl0?3= zv|t$2xyq=iUCMcs@*1X(_UgQ*`$?A7U=BKfK<)xx)A3V%{TE`vd;=tfwf0>C~MDH<(!KNrBBqnYH%))2=lMS6%K*0c}v%c#* z?7z!+)7E4}{%GIS!J%{@JtsGpRa`=X@A*GlfWX#*lsQ~tb~0HnrHHI%qYSk!Q&RFz zJY^h_XQSvmeK%9*_Q1;jO=RR-pz!bf%rq4V(&gTn+H5%E@WVE8AM5-^d@?A1Cn04fW6z`7+tQWzC=#G zfZJIh=C#Wf%c=VUHCg~ql~hjwOojzge~u}yIS~7?;)>sZ)&8HSyvd?Ym=9t(O&a6t z-BxexA0KRfA-#C%(hI2etI$)SJnkwgDn`V07i|9f*AD8KD)3Rz0fDo(eEE{q-Xdoe z^_2V*t5SS(9e1f=LxE6=#<+35Rb68x2B>$HiG&ia%PN)DLj<60esWnNIXhbA0rw>x zAlCBFd~$DZZw>Ap5a5&)B7|2DAK_X8T>Azldwy{P`5T&#%?6wT^YYko%sQ$yul=}7iI1%Vz(?}z zVBXqD57b*3DBLo)RV@^iz$a4AKdTA_twk?oadGk9qemr9N1m{DeTCXQ*)8vTm$JZM z0NdylIO4mzyXm>Pc<`K%o@(yyCIYw&-kmgE3yP068@u@(_N?m0(fPj#hi{#oFQBcWFaVC_+-hl4J81K$#AWT{ zwFGCXuiW&m)^WA%nA+OTJv`#uZhyWW#RG4?Dj@K&of1thZEtTIo0?v&X$+QUQ61mg znU9phjIq(pll}KGGBh**PR7p3)_jMOU$NMVW*8OE%YXox;&-&&-Q8u5bF#205A0u{ zs;z)Z?)rytutW>p^k*7<5_&aa;aA@DP9dYLT&hR2SfE+Bd9Y>bz5Sh)K>h5)mMkZwfG!ij zN6@s4HFKZuuV>`t;e!ZWWX?Szl#(-6TU*P_$r-+~SN}KYty;#KhE4 zbVmh)RgUUkzn&}-1aess8mLZJ!Gwk*N_P2jFmP|s>K}3&9RqSPZcLXtn8k|ec}xqo zGK?iGBu6=|$D40)X-S~8H$mVow=|4>W?|v#VO&MP3_E7zmoK-8#VVzWUx7MA1fukcQwY15m*ONJP_CmGb|o3Ei-dUt0XYK zVVr@cYQ!q=4r=u!JX-eo&wfnMF68y=RzNs>wxhhsl0G!3UW3fmZ#2H^@*yTA)xKRg58ZUM(dYI%HLZ7ZUtXC` zfA&-|tPumAmo;!!s8uWrUSpxpIrOraoX{>B0LXwvG}rc^Nn`-C0`@`;ZUI1fEa)C! zPsFa2td4!m?IiRb!dc-bfqkee>9on%K_WUI)Pt}C{Pt1F(1!vJ?GoOWG3eEI-_S+Z z7i~<|3Oq^{4FHqEGPzZg+{#hI4HG-*!5`4;XW{&qr}jdxLW_z<*fs>PM;54FAoqh8 zbO)?n5Rmnri>ZK-FL_uWDGze$45Uu{4D3lYZ83?D8in^ zD1)f`Ok`+JfHoZr20Uusl67RFarF%+BUqxoTuhvX;b%kB&&F7f!h_qoZr=x(7lmsVlH{m0UPccE($oNp#4C&Qe_c5rY2ylgz%9Dw?9KpWp1RvXkh z-^uQk9TZCU6lVbD6avT)<#w=bWcb*qlTiM9c6LTB zWc^-ifXs#^>1|#&V7!D5cwjazE;F|_u6nPjq?hU#iN!#XjDg|}8P-6E5wv^2#^(Z{EDWb|>}cj|d^qIf`^Y^4bh@ zQczG>O2pi+<^e-?tjf3N&6_vzVs7jxcm!DrM!D5{`oS8985&l9jqCN}ncRRUyNV09 z#il+v1sq0&SCoV zckA?JDBy0>ha;g3utMWg*ESNp_Wtmtxeo>ZfT%;6Wj_my_?EG;D*#wpAj^SHRET~2 z92c1e7C0Oj1ek~-J*L)kC#UmP71!hHpX1mBc3~hWDk!{E>;Mp=xMF)(-TiL;vo%L> z-MdpIXkpV(9U3gP1Y@St&+F3-VIWv8sy!e0aKC7TN&5yQXciq3L^B+Uv;-)#PT@bS(`tAc zrhcx8a$C>-!&@$kmK5p5#l>ibfR#%W8bce{P@1Ox_A`wVFbm%RUPQ$S0C1&35 z^q`NJ4qd;*$Y?ZA@9^I9m_ zwRh*mKGvR;SVQ*%`GTR)C!0{Pf%rXFu9(k}>*9k)G|3poAUu(UwSlf62MPk@W)f~! zY6v<1{w7xBIrU54{|tC#)Vp_C!{rr(o`t568d9vuzpZKlKD9MzW_d#K5RidnanDO= ztE#dYiHeHy2OUfR%MLiFi5Ah$)|L#YF4jIg)z#HBKPUz(t8{7akc*B&6ru$NH!qMi zI&lv&G>d?0<$xfeR`}B5!omoFO1^=UlDCI*y%3hqda9d3ld@FpYiDP{a8*W3|I-&5 zc0Uh|U@YWsH9)g50j*i5*7-U#1~w6!zRpepsk4KtZx|$A2jY=x)z2&0nT;sC6MSIz z>%QP;u4k&M(f_Q!Tm&o%3&6|I|Lb@5?L+s>N02T6@b=TXHZC?c6Xb+z2B*bz@+61H!7zK|e1(#HxHYOmZ!VEYt1()et))zbAEMlG9Bc*th_tI3Q%wW{(LRYmkZ4D+OCx8Fg`$*!vG#%%$*0u zM$zW*_G`#+UV+g&G+qlol>|QpjXwh9=9ZBW?Lv$rAW|q1sVW7uirzP|XoxnTvt1i4 z&uYK(Rj9!Q7d~EgjhOWQ%HXT@ZE1W@(}rkWI*7w@r~mI8npGzG*Rp|hNP&<5k;Q<=W0X?7%1`tT@{vd~$KtI&G6{*9i@Lxw5jgkN? zv&z)Gt*y~m^X4HadjN@aKAICFaAiXVupm4Y z06U?1x}^bUXb#An8S-a#Qty(w zW~D1-UK{F z0Re&H)GY3P=d7TpksJs%0)G3}+dJaLYyLo4nUs=}GEcjd0d$VlUHwW1LFV5PQE_pv zfG0r+jSew>`VR(o0K8ZES2{zbyBXE6~p< zS`0W2zz3`dW@JYoY(Io}fd?irmu+u5gE9-ci7ZQWGvv#kJ$vS+mR55^8ocWH@9$W< zYYJIjzI+MhfIlSkw0d+$Ak&44!ON?wrhVx$rE8@8KsEL^W?o4%K}r@=zuL92lU@kg6Tm66^cQoK`I0bAz1^FxH4{-ej%6Uc28 z_S`-QmSy%mT%bZNL6GGCql=;eu&a09ytk>k5G)HX_xty6%e52GJK`mt3s+QB6xe-) zVOQt2`tvMP4=jA}3w733S69OTqvyLZaHBaGts`~jB{B`pApFOre@!@oXCZqz?YRK!Duhlho#6#%tHCntm7 zf(?IboRwf>qA~=8gBwQFEEpp3;8=pO&IIuuXi;vy!96c~umq_jd{5#p^gwo~1!S9e zJ76HM040G05cU3j5JU@L;K>y(or6dm&?)s3un!ifRL&E$Le@brIp-nH9eD4W?7e#- zt%w0+&OsXG?Oh!z?09cxWp&}fgD#p?RjQS{2?+@>pdLmX%gf6%v9M&0uY&TXep8zD z#tr_+j_@m4f}+|Bzka;{NpT+3BwY4-DKS(t`n*vG2n8B3h+Rr6{>FFi;9R5F zl=txT1U-UM#C|f0un)2o%QH<y?J}@4p z{my+B4 zQ?%?+iaPMUbx{Pc> z_&)m>LPFFkeRa-mZfzB2T?hgYdbG~1uye^_xabOWEJtwJAg|Ke*H<iW%xI95+wgM;D1x#~ zWX)iR0DXm=aeJY5X;fn3YpB0(I3%FEExO+yjV3wZ=rZ-&`xB!-LjE{3G!({!AD7H{ zUmqEy*V)+E(vcr`KsZC#&)HB`4#%>~&Tn`5BDYZ^5#nrsj~F{Rpjmb4HYT=JFs)?3 zID=2J`JFXwwu8~{JuN4iS0GLvA5soe*+xJD|2Or%&EegOp3K;SmuFVnQNw4iegBC24!{vH??d-gYPZ|C^< zcpfZE*ai-`^f0%!Mhq(h2jwcHrJ&}O9ykDYCOG~+gAM6~cmWF%m9bn1dpP`r@Bk1j zv{i%gx(No<6VO(91qI*Rcz!4Gtw8SuEN+2Lf~tm3EiAGi<^*k_tfHb7_5ocU1R1Zi zl55c=9#9CaAPPA-@viF95~9~O7^o|pXdbvh&_@7q0Xm4oa`kx$SxbF^vtp;P}MQ*M|$}rlHQC$yIEk?*b zQbjUKy(}!`KoK+OH9zYNacDv2G0(C6*q`%|witUEy6NH;^pm2%_^vST_U9`X?rlgZq_t_p2VJg82!h! zN_rsaHKr>uT=*$-S$YJz1th>Ti;9RqDOUG5yg?lMr=%t$95iuo&a%~JCQ#RWem={| z;6tF*qeqXxK0tvDK&M>Q1T~N`q!*CfiYplsUc1tuG@)k=vZ^OA`MQP2szeJ2V44HC zD%qWclB*ha7%4FrGFAmEolPn3}=b z1v@QuvWbNv6;LW18(xB=V{W9Wg_8u`6QVJB&M6^_fNNFt^}9LebYp<&q)Pe}Y+qx8 z3B=0E$~CNU%j)0D%%dihAbn7L9rc$nqpq+{L0(>(ld{mY8=8%#V#;k$NIM1AofQ0-d$D&T^bMRYk2NnwR(dCKn@`FyLx&w;NC{ReTxac0Dw1; z_dnIuwZaV~K*H=zjN3ebkAE*MW##4Fn`<}+%cKu1)Y^4+^rC{%<3|;)7tFXP>!Z{$ zj^H6cGf}NGct&Uq2cNO+##vwuzpNiCk!gzR}f<;N;WdW!On^0F*?)Rwx79? zlam8ilTexrp|w%cHOOPbjTg`z%h?B8TVLpMd@i{!knll(z%4=@!wa8VQ7q*K#W+#WYI~gW!3(9-YH6lfc?R)Z%t}s3*D9rmU4~qv zgp1A9665;MdBWSmAV!6Nd=b%Q@&)i=_I;j2TuTHES@dPyC7t~2Ne;K@Ak>Nr_umju zsv+^AFdxtfl8Qhi*un-j+ip?`D*P27(RMjOL_Q%^r+=WB_wxHIy3!pEk&GgFP(81kpGk600`8 zO+Uci0&0pTzc?rIi-h!0>`4<8gT_!JP|_;uG9#Zd(h4FPB=tSEe}_)4g3QwlK+*|Z zaqAAqi#R_i9v;bua16DeWmHMwRy*8_Q-Ht&G$Q;Al|0u&Ei_DtBJqIE;p7skZclQ+ zI#BBPpt%9voI82#KakWxbLEQD9}-?M(mtdNglX^NwHhV8%j{J5jcV)-s(c<-SqH<) zH7?SeEddKDaf00 zAU-5H-*-La;o?Fw6Bj8dU&4y;VSP4j56>8Lu!j1!94%vpxB#@LaER~fZIomZR)|C_ z>>L6HS<;}tbrDURmOtOO2XOl{{>_aPm5c{)@gw0;&-3Lz<|?feAffb+QpLYp&wwar ze!a1#?Q1AGGX+7p)!+@t&YM8M3H5PR#>60!FA2A?F2A7)zapTJUH^mJ(_4}e;?C$n zaB||~7Z5P9v-^>-j#aMHU71WwLiceejH3!@_HQPZtfGnOhql2Yo-anq{ zu`%5&Gg5jwI_s$pp!iw)`*r3X`EnpFp@s(_pKyprGJ&!P6)ml$6+78X!R}%QG5WcM zh2RXiSMY5acXHkQXo2vjLxk|2vhrPc4#P(83(%QB*;xL24y~I`HbZ575fe0vWWpX` z_j$JiGv5m4Cq<3i=cJqmqfOe(u(;s#>won&*mb}czjougCs-h54+OPak4iy70ae5@ zGMGVK{qw;Lf_48(yaA+{333t?h4eyPO;wd!%GtWmkL(;!89nXT+1WvV#>!C9Vcq}y zRL-kOo`a)M%%M#kz6s}ZGyx%DLLs#khxUZbV=$WRnRy|}H`@C8ita;giiN!rl6=AoV4B%>&O>cSc2^2{n@u7 zE`!U1>)=6Ta^u^1YsgP1pI3Th(f3r(v3-%5&+*VZe*0j z-~U{VY8OASD^U{;3b^P^Ax#hju0lXxDHE&K{b(M~Yx07os`$2MljBpY26nAd!{Ro* zi?W|Wc6|CYu@c$g1Xt=&=|Wbwdmg;S;SSZpII>pCrFzi+L(?`XGg^;3v>w|H=}mA92MlLq>&5(Wla`TL^~ch} zp_9hI>F2#+(!F`+G&d=K>6@c0$-!ySf*!L)nvXEP^0R+q&vS7o2yg%dxs|+vuLFX< zuy(6f{|(STL>hn?=nH6P(VMuyOf1)cQ)w+aJe=hnBPoXow}w#n3Q52J?gk{w2gh+u zzb!Ze6uYxl3lsspzYO-iG>qwSzF^q^pmTrp{oF*gpKtxbL9f*zXjltypt;8Je_z`b z3S&oyJ?4*hYDq?c2KyF-d(c}QNMDnmpN^hhK}>ns!A#UKYnpef_}OeADO`le-*JOL zK+do_oi7tn48}|(+dj&x@sEX3H4m^I-iBLZ z`A=VXXeb7PULXQI+;>P**NBgP41no?9q|y*%uGQlKY-wi3S7?s?wt-y2(ZNqFyneg zv}1tKLZ{4=a9GU0R0xoepOwDBuwemVVX^zl31l<7T>yibhMKORuXM?YGSf_#D)Lns+kz*Yb^@%$iX zhD1c%0Y$7(MEnBtqy~?~G6(=89_t5N^UIU9AKO#y?Vtcbg4mjE!2*3$pqCIE6Yhq{ z6}x|@1e9gI;t6ps2vffS&XpJA3;=26b z_Z9KyNZ#RA>T`EMJtSDUL24V8M*uG3MI|JJf_j6#4+8^(*bplPK|E zPRr5`@yR26X5!%Qn0hzf&B6Mhkv$|GSkm%9HX-~7pLsVm3at=-$jr@%DZ98hJIlg_ zW9SQj1aROx#ngqGrZvvRi;9bFlnCD|W`pSBufzEg+w#r#)X?V1nq3QUZT)LU|cPNrHldGXS`|{UbWwUBS}W z>p7n}KlVfX;kZ8m$35pjOAwTGDv&W0f>gc0x&bX{d+@?| z&=G=TGy@+~E8^$ltorpsE#Hxs4;tSs1uih6W7(hmfJ!M*Ff`%I@lu)}*epzff*Kn( ze?GCwHb ztzObK5Q2m>8EirTI4=P0V8OD>ZGler16iX3EDjhBu5B9E-?GLfqV|W-%;2&YYi_2okqXJm&QM9o2yc@5Sv zhNer`&GZXa;!6CMV{kzY{nCh~ZJ5EJLAv)Bfu3>x<-T_*@F5D0~o+9Dme2e z!0m7|K%e5mLxOU$A)bP->lNV(EgJoIArUO*lJz6810YwC&e#y9N)1R2|ps4l?RqS05<xs?RjoBH<*< zKxmeIdO-fUNJm$+r2{k-bj+8)H9;X^0RifD=MHb9nRyizGH)NB)zc}2`D>+zA_>X=#@w60Q{DG_zYC=iU1?M)4Vs10 zAW8E?vmuoRg-EGLMCqz%k|rT3O_DLOG)Re}A{2`d4Jsm)q@=;~{&n5^?ESp<+0S{M zeV*6rJol^HeP3P0TL1MMzTeOE{cO1P>w%(#vb?;bo7&8WAwwFXWcVDww!B?NWQ48# z%`LTO(<{`Ai;C{tySF<%q}7_cqq~y6I8iO-qPyzE`_=onbwoPjf9q>~>3dGb*Bi(1 z_1MgQ6{DW@-_>ps^bPd}C3eb_Cr9dg^jlOqF=NPq#(yM1IY@jJ@h3e8)jno2*^cr#VDzprpTeTYJ{b{U>7b zzxWz=t8~Ek)7**^G>>Yar()fXbls$OQW(y-4ll>YPvSVKuFrh4vAdy1m)5)G(He1m ztd#zyxB~YB1YV9_JF39MO66pmN?2P#vy{gnX22zLqpW0AIO1gm2~^k_z0hGxt_d-S z5b$C=)h`U;L*_@N0(-sqS<0W5xa7+jm!Ts8*8(748 z8M;(;;EWY~|IW0*Fnzrb-w)kn`Y2>+^y1I>EUMs@RpGRk|3MVsd z?%YF<9UnY;bPom}XX9QlIX>r|)icR?fai758V^HCs7Aqy>c2h@%5LuLxHbIBuQko3 zd$A$lS#s8DLYE$j>|N7QwFE()be)z~r~Mj-rw7JjxBLi>Lx+StO)uy?OGqx1tteZALb?AUG~fKRS_*xN_UD^&2}K(im0 z2vLF6=-79CL&IBTO)6Q@yl9mFX3?u-mR$dvqfg>;bI)HZTh4CoOsRuecrUH8aNpKg zz!>NtFc*+9H#UsPPA(b?i@+Jh?Xg>J-`-@IiQ46#`iIxt#U&XEnQ9^}T>t5O4s9%D z;p47~rI8j9SNrW-Y@O@!d;2;X8(k&1TsdEHg|Z`)O**n~r*b5X$$)DWhKCs^QshVlmRApgb3I z2?cB`(6NKO7Ks<-7_h-*d)XjSby8f|{yL##x_WiLx|serpUlioe&5vFw^{#f=TKLt zewi${99^l9zt|YE9bVCjJe4|hK0kk;YPR6={QUfiial$J^&|ppJrENKkR+5{&RVxu zq$#c~NqkgU9f77lb$rwJIp;54RFD|{`m*5X^QK=tVU&g0fi-E(BNF-&psv&^Y0-R3 z%ZCP9g9i_{Z+k=Q_OGwqFk4F`)VWd8sH(D*dbeS_^JD0}L6_gI^ER_-GP&+vKS(tj zPF0v0kbwzD_&StB-ndgxQtAvY1_!>b3 zhQBnxjE2{}GpK=hqf}aR^{$TX+qW5h%;7Us$}AvDY}Mm+CQS)ur$-^cl)NBUV%x9A zHA4rt-y7(0&pHWIE6nwEa%}pVF=^Hbhuf7 zS!f>+tXa8E;-nD>5)tdwS|7c+g(R^R)H+!?HT+pG8m{b$yo|dW1meS$07tGvf(m3$ zaMBFDK)q05KBIw@r0|+t+{fC(9g(Ij6O zwQ~r{lZG#Oa~9c!tYcYmUEcL5z2&)8uuei5GjMEwQMsH!cc}hK&y!x&Awd_Ll1Cs}{*q!LbpYKDe1@A+)BHak17 zlA7GrX5Wj~g*|>uw#{)ih4zrR)u zu}LIwL|E^qUfpqde@3}*9i3EO6R-FMR!$3w)Pm-ICb7|C)7xhxZ7&!Nk1bw`x``1()E3FqRAbY}%PqkmTfK6T|)GGACZdfeANiOD8sUQ zHzRoOMOzh2e{5q(rNTymV5GyiBG@?yIxbxJ`olD9$O@Q|v*vM}P_9AG@1H^A}4r`}UQ1J!o>m?R44W`^L95n^5QW?%n%9@mFV7lDlJS z+Q_BPPnoaRsN!%gXg;Bs95eFdLfl?LbeSgg|AZCrk7xp#f{n>K6{lB7M)Ih&PgDNb zm7hF$LW0KM^s~V)k~HcaNd`HoY#TT>begZ)ZeEMvh}g0EIsLhxZN46$#=APB_$zu2 zmikP`Vl!UR=8t8M>{Ui1&R}WK(^k^RIn-C3rIkXavSa=wksxE-DRu1T_bFUXdg_te z0?x;BK8vbh(Sr))GB7;h{x9pkll3%S*x0ER(LRK7_X&rX=y;Q3T*>5h!AlSw_Zt_vDMZ97H`45yYyRZXJ9OS=} zWp6=j%2Yq~rR7r(@O-!^<2JsT`A4w^mg#t;+sKHzBe?RsC{>RAzW?QJd99#sDsS4y zU`o(0#GFf3(LkAzb;0?$7{Cym)EHTH%GD+E_5Gu($8gqEWLx zV-aUJkTRtt5gKWcyaT*2$ua)Cu&%_bupxvj|ES}~E=ZOL1xU@z%v?2TfYKe2k_55U z&7kIfhn@>e9}!}Jh|Zm31{cmzsZYBF+FOmz*{^%YdlXSjBIkn0mK_C`mLpZbPq+Jl zZCm>C2j}ZYHRI!c{Y5=vN$EVlc8N~CL+8nkipV9=!cmpH;BnAsdSh54Eb%2S7wpPr zhF1^n=VqyI+%wb^3#qRAn*|(CY)2)1S2QyEb7~zbAx@MUw_iy}xQoBQPFe498(aAs zCt@;vx&>3K+4zk1tSL<@tn@-GO8UsL2^Gp+v|mWS4>6R5{QROvp-`NEx^nln#aU~S zR=RB4IBRL!(mfg3*$4go+Ll*!=r^*t;_MyrZ~sjTpcvuV#p@O{S@<{OFX;;g`OmZ- zr2RG^F>V$h!4j=$6e^FdJd8O5J=YbkSpeKehfJS8$RAV9D#O5Qx5AzuF7%0;w7mmO z=E~mbiU~#IKkuUnvk|{R=Kue}U(MdGGqTA}7V-%BsED6$3l87XA=~h*%}iPWLb{FnEEK8=KzI#6_ln)q=#8G*m^#YP)@2 zWmdh6`uU%3QP{=fEk~{C0;_O9|MaGDc4!#EChZM6sjBYe`YYTTY`lhVE4X*)>-yG} zqD-Z?0bbJ64{g)BwMdIF@NDdW{ozx6{S^MJZ$zCb^u*{bpLz+L7n6?NP@uraCx0CS&!i6WhOhVRx9<_`fenPhgk+Mb+6nukUW5CtoBKA&Ij%V4X>l{r zdhP!I+&~^3D6Ff0T2>EW5oq@2=#9_C3LSm(Hl4ssrq#GnzK9M6LF|@>Pa3ocsNvf2 z2I+>$U6jX%?9HW^UC5S8!xkiwP-mh=(79I1LGTn@Is47dojbvsB1d=5CCW0D&R1}I z2wDgD&)nkfUR6Ksp*ONX;%8@6d``c$g?6HxeqBFcj){q0mAgbF5_x=o6OdI%Wyzr` zITRSUN5!x~BC^F$$TaD>(ehKZrg)57dAB9k$HMY0>uI%q|E!xM{vvep9q?8;30IAF zLt}d7)3`pUe@z{NINn5Pltf}~Ug&P$)orQMpinTYI21Tbk~S;x4(v0aG4_KAuf#y& zGvemnd54i>()Tqz>Ge_py~C_R&3j^U5C6ntua<5^fFO{$7y1OB=- zceq4Cfk&v=uKKTFCwCq_iZ0Ma0AVD%b_t?mULke{;sa#Yf6`Ha>96=y56qEn01h|! zk88oMIAI{oj1Q>uODu3g-Cfsv)t;);=tg$hE7_W|kcHPg$W@!@8=YB3g%_?|IpS8R z+NqOG{#d1nBVOKJpHF;I{=|RlcEiFh{J-vYrH5pNkp5l~r(Ik|M_O`W34+P#S1c!d ze&o%nwLLsF)h=ZZ0tWrRTHE0@vnS(mCe!Ic5Y?g+Ui5K2Sj~=}qe8#_M`+^v& zZU1Fje%h#4c%3DA0!Fg7Cn=q)@(qKG!$y911{xibt>8t= zIoxkb@59*@@6g`2p*?9H;Vmqm7o}0t`>rfI%sv;k?2(I-_I$wFvYY;+_weC)&rBXa zj2dT066hSm@6EznUkA(A8K=$xMDD6Yf~ZtT`@*vLkOWqW;RWG*S>?;-lSJO6+fR@K zwWZH#e2IR&wCOvnYd4Q*j2}9FrKP3PsRo;VsUI5&+&ly z2}C>vQFFf5`^5Z9lZiGfh*`QHBuSETNMjOu$nd&v_)^l5k~YN6urk=cZ=W{0p0`_@ zVA#I6`}7LPT(n;GG4mY$y~RziZ0eMCFG|jnTrQ4ec?nkF*u0W?n17-OhNMoOg!)_L z$GBAGyW7t zVSDbueE^j?Q8Ef&8GSr}mSCt6ArcpO0L^fCNH;aLh|Ndiv;fuZ>yC3K@4s5+p7|ot z;RsKXPhWRfFL1GlO@Su8+P&b$$ttYB-o?jm6m^2ALrKkw*KjQG`c3}pjtKQ|!}Zol z|BW6O*`)qQQX1kUmY9Q5?R6kNSUOGtL>?e(fmNXy3LkN2Q;&>9oh^86kO}=GMQw9PRW%vh8Q>G^tJvXqW&y6bolvJEMPIrTb_v)~$eX&^*1!+P6CX51css z=8F4MC(0_OUzm@AuG>$aXq8I~_d)IGjC;t{rk!l;*`Z^{3GmPhl}*=|f)ZB`S?}Nw znU^y`FBIXqR578-LK@c1gH_;SHA|J<$%{Sc|04D)Sb=VyGOU#^f}ZaB&Mm3q?u`Fk zt3nXqf6)XN6wl6z{Lf9W$@(*<|K0;Xocm7?Y&;iH(t+!HFU3+=3k!yRz-{ZGb6 zh$eP{t`;m3^?p{w9SjY=E?5zDKRRIu(#F{tQnIzWU?^crQ!73<3c)y0yP;p7vDM>< z`3sHsW&p4%Oeqj1@)1_kb3(>mES$QSo5Q?~E&V#qPzkK$)S8bIu3bREOiw>x$dFw~ zOS%8f<-I@OxH8de^Tv(johSe!iOfS-na$5&M!KFW7%|&2IiF^`e<@g zx;QUi-V1aN=V@rI%`8W`KvMZ6noVhYsa9x3g0AKu8r-vIkG6eD9>wH4X)C{hf>9$% z$Vm1m2I@&$!-Ft6o4<^yknp6RjVrWBF0G6NzPU^St6EMI8!O9Xj!Dz=MT-_0$BL#y zZGA!btzRAX-wQPK)UQkJDh`x6FV6WHinqV6?B-rHs5l#zb6s$wUYb4r9G|$hAEQIO zsfr-qD{uBb;#5LYJ*G#twyTk!njUi9R^&D*ay_}6nnOYR=zUkDC+m+U)WC0wDxx?c z8ZzO1S#q#U-HD$ANlz^+j)a1$Xe5`~QxA6Ei&CZ+YEJ;3yWH5Huus|??JW_x4Q1=! z!C4139~2=pslZn7w9=w>GQm`OiTo>vkf!crUe70_a*gZ=v7iN??R+XRDiZf(In)eD+Pa^Bu*DCn`sY_3o_xmvG zVg(=d79QeX6wbU~kpLnv0g61=tsTwGSb##LElMjA9SRy_(t%>xc|v-F#%;vc4<;)p z|LNO}lb7rtG27<(kOv@I{0ZSAn=74>LvTuMdbjoAj>nzRmD*egVtbOYWq+ln2Oq0Q z_{1m^w6^8$SqVxroPsb96O}A7twM6Tj$9i!qhxZ+k}UPy@Dq#ONsaUPRbMECJAhns zt&7%6k+ery#HGHjJJsLh#HuHaduzY6vYn{bdv>!C5$;b!k;yV~g5@{xXlO^#O#MHB zjVI!0+qDbL^LXJsKu^y!*6-72>E58r8;1B!gX!e=a4wDCil$F z2j+X|*rAqndm1lUmiO^~^`z6Xcf{4B?Izl< z?9Ss={e$3x^9%q|4wbOyJ{gVW1v`V+@V%c94r|(?3diA}kJ@S!qN)lMqjn}*L6=H6P z`J9#-C-0Qkru>QAltW8iFs;3X3Vvyg_n|wi|4a=0XCmJJF<&DaZ_H&v6Red8YoIfM z35{MYyoY?&%z!{XNIP0kTuTAmoBVl+FmNE%iZ{Ee2__)0GD#?(qJXm^>jRD*TTam= z@!}M`S(iOFs$fn_ot{S(oxu%6bIc)bu;r7N8$7=%XT&bH0A|E55%Dx-xZCx zD(d7y3p#rjGf0BGu!R+$3134vuDFz;GRq%&0q3P7Izs8Vp!_LUYAx8aFk_LS;9h@- ztJH@I{+;YNA=i@zbf|L4j^r?nTmRfJB-6*o=Nu%XzW<~9_fu$rod=P%+m7CS(^#ZA z0W;(+wE{#wjqzyA<9`8od*ei)(c~L!`1WC`h*V?1K^p9Cp!$dE=|ej8F*LNgcm>x} zsF&tzy%k5iw$TK$spTXXI_Tnd`^%cN>NyiopvjTIHW{lYYqZ3!{DMLGa+s?RZxeelWH*lF7s~uu9VJ&KA6{LT$aiTSz*K&Yk1DnbglAFoN^;K(B zCt3~DoY4YS>1{39bAQ9v3%M4J4q~t^jb8?e7{{;!VUofrXevdm@O23-i^xV3A{bus zJzx#l!P7()f+qkfohpLJO%Lt@tMWso_8kxNOo%^_n)5~p+cyU6d zW9k%{zsLOjU((|sOB~7-EFz`oK9BW4(k5I%-}E>ps1Sn6>PX2F@-~fe<9fgs|IGse zk%yU;WhZU@7V}sx+PCUyQkCz68)9St=GXWc#gkjExn}(Jw?4!gri{^K%?T~c!-t?P z4Y%7YUbKR~ud*OIxNP`f3O^xDe8Gh!@e&dZcntyVe0#p#kE%_iFOhJ9Dzv3#;@V4v zQLQJ{^8k>1Fl`7hXp(RF+Ni!GoIADFIR8>-kr7b>z=CI}`mhV8Ly%xiY>5I%B+3EW zyT|q(GHh4|<&HqOP~u9AXbf~b8l2FJP9!6c?I&c3yjZDL45w%Z=y4%q7+cUc2xW9i_7e_=xnXl!Bw%_{+<9t_-ZG-{^3CG`5;x&nj)CR_) z`!X$GtHfiVbyug9no6z#zV&sV>gVZYPF`%}@CrU0d?uA0Sn0sxT&A?{pgG?OrK6u( zT_nlgKy?sVNF8xEwS^C6fA=D*njOqEhOY55R3$vYD(6QWnbOYt%n==NyryA6@i1KG zc+%pu(sAbt3kMMnC?90})pFS3{^#R{yBW3FovI%&{dN_f{%F?^C)2H|zU(*otk&)N zo53vN!{-UR6!buCCZd3E?!D++)y{-hAD=Ou&e`Il2JkcIh^m4CC?s<|NIdv zFJVa{8(gXQCD*xMQ2TH6;PL9YJJ+*s3lVelfNpZ47~!cb{l zTT4?Y@H*j?k8yFk3W!X?ktl0LsuotDyP`Qf`Y{L}4+E@5=39ob(p`=}Miz880c=GTs_T?A3m)6p!wHC3*@_)BUX0F z`*>$YOO3m|{=cvvQ6FtC&K&^ybY|`VxYh}aJrVH@##7HPU|%+aT5^VmZ)4H3|BY%p z*9?^GHnJ^VS)B(i)6=HNIm&~5h-fIua*kW6;ulPSPA^lN zix5xB9NBj@VBjgF`U-#?b=ke}uMb!03J7LC;Tsm{7Ib!K)2&-s$P258S zD8#KTveUfVc0C)Hg(ObUgB;}6;1h~VD>#4!zgU+19&EK700OhQ;?mBhKU|5yt!N(*auQ^)DjV!8?7htnf+Se}XA|tSST+fL2vMU^|8e@KBdQ z4g_@48z&b}n!dA*pnOmdEC(eNo&1BCI#9npBo;6$$x=a|$_szNG~5vc{+e7`c=pPb z`A{6|?vm`h2PiROw;kuTI2S-*C;gM*EJn$QXiKn|Se~G zMY7;vvnVfr+4k){xZp&Xph$(~B~x_Y!$F7{{oJNs!f^n~C_4pmmR)6(6p84jQ=a0nf zH_Nb%jd*e>rrK|*zs~jsx?*mR?sH{rmI(A>gSwv&Lmj*bE#q1xmTok$_QE(+r^o3m zFs851fc}EKK2U=Ux*1Mb%X8sNh8r=rC?GK_>+)Mb-` zk$+R_r()Ktu~~{Nvd-eU`iM0RkZW8}t3KG+-Mi#<5UT}vg2T|G*i!I9$w|`v{-l*$ z{yncE0TX3pGK+>|Nw5$#h0PNZK+fc?3f~%9( z3jM%nBGv^EC^GQrJmCLRIY9EWOVEp;?OONA#YMCXe>9UL)bRkC|JeSHta#O2;n%3% z;cx^$nf=yQTY0`*3GKReeNc30os*oHiq%S0bNTnrZ!}eGM+)^J%6lW(kR9e*b2zt% zXn4Hk16i0t-$hg^=BPD8tSgnCK)k+e3TjkU{5LJY?~hI5d=gRBpfy{Nl+XeBi39_V zCW(Z;o=wH2EliUOtIF!|m+7o8Gk~RQM&_q*vWTou^j2(*$Pn{!k#~7V%TuwY$cTj)L^?h`Fd1V54!)$g^X-#~K1p8Q%yv14OBoKLgEwa^I z*KsS&E~Aj5>KkY|rRtfabWEM9h@3d6qs!VgB$^3*7m3#|_%Ov@-26V!G6=bam=Q;r zI@`sW|1=hB`v2<}9GokdV4b|h&G{?}QN-@aac zke#h8tk}1HH#+p~-@lyV1fvymhjtPkD@tRh(x&e@+dpy0ivulOO|yu$HBa>;g%yRe zx8TaC|ApWPyiAyLNWlw|ew-If(?VYXR)o>v1#271e#gQ=_jp(5&6k1@yAOM)@oX-q z2s7=tNm(d+1;_hr;ELcP1vPiYcw@~;jEI>l{hXD*ekVXcMpjpwOd*b;)RQfWaSs%e zM}S?U;yuv*F$65ib*rdknYjGrebIv0Ix*9SrOjK497h&L)W~%o5kS%N6gFJw2~J42 z?guMd&bq;eXMN$~civ?=Dg%1YLz!LVS@bAJkVrIq5+vIXe4P^yJq{YCF zM2ahr8TDnt{n*0ap4O6x~+8RN9l zX8Pp^xk~53H5`^6S`fgdDcrIS_!XJb>X5CA;Q(ot)9&57kUrXpmP@o#a$Z(-8A1R^ zDL@9MXlHm5Vut1E93OxGj-WpOOdt%+^Hg~`7u-_g_)xhwK!b}pV&!3B95wVA~J}cuas<#cq zk$%qWE$F=BA1@tY{r!1?ZQ*0CsEw-tpgs{fCW?QUNbSO@Y43UGK~4@D;bV^r7bm+* z=gEb@w$vpCd9x@pxHp2Pj3X^wjFZTfI+*|QhvhJ)a|a<4FZkCDbZIDFg~|hoq}tCU z4H};@PrbjSyO2BX$QLC5^2b@{o9=A9$( zF;pfjx|x)ebf=s8JzP|D>7y#2nTIfG1AB}BG^E&DP2dcmU9n=zfg~3@$)pHOMs{3^xGykH# zN4Pwkob+MUs`zIDbo}H$-Ob1WNzO96?NQ4}HF&u^=hv-nvHLAhq=dbZq+V9Lr zx53dr212u(L(2?}(+trsZZ8M+3Kn@aq5r5>ssk_n6u{f4YQ@=H=nv3 z3B*geiBTD*dd|)zf0^SE5hx;3RL(=>Sf0^_GPG8^f;yM6M58lb&PZGRzG>he2h^T8 zxkr!o-o@`VKs0BOf4-jkk6-bqd5=y8A~yE*T{}WQ^!kl+-Ra^~yA@3z2imS9H~UMd z$*)Vrp#1IP4*N!Z3H|PI>#>psIMd%b6`Rhrpt5SMwqJACp{9f5;`Od9|6GjAH9Di3hc`Y>l;*L#N95;O^$GWd{Ed_IGbOZ~*YDy543aO-9Ib zmQoxt|1LF@lH}>t^*u3lw4|5aS^Sz>1EhS-xOF5*&P_fc5*X+kE=i5S)rBsw^82e7 z1pELLMrX{LIWus>rim@D1{#FY8XVwgzHDhrGgG&P3k&U2Gtx~#oUy0>#dv^Y-Nv~m zWY?b$~x`U#^c5!pH&hMW*T{ebpVbZ^wKYr{e#a`d;^JT`AZPsh$Hq_!YEJ3A)bjsKuRwzo0 z!eZ2&jg1c!SaWWYxYJHB_LkMl7hnOed-~wPjEXwJfqi&3Mpe46-O8CKJyoKjGi=tZ zS@hBI<=+&;eQ2M6dq@!r-|(!%;8vFkjrX|8wi=0q*osC#Vop}9+|q9a8{Ty@0P_e2$(GH?^qs)?CEY;y)H8B0RI zwM>XDZr5C@I}JDebh_#1#d(AKkv=+n zwiS;ot8SevqGR>A*Wg^bg@c9;-Mw%SzgBPSgfWQe0N&Ub@SRORW4tFQ$RM=5yxd`2 z%nivTb5J8Z3!`a1N^@ewa!~l)&zd`4;og#4Bya%B#BI=L~4eCiJ2+p?_)Csv{fZ%kC zFWU?o%>OyZ;pv3d2r3lN{XwHk)lo`c9t7D6!d@>3BAg!R$Hok9v%aN<_wX4;TixAP z5+0);P&ndQsADIeL0E7_fgmU!UMlCe^x&G?GqnN=w`7xb9=ka>80%9=O%G;;gDI|@ z`>XfiJ#NN%?^|Bl{w(m}@@Dj=0_``soj0YvjRaiQbf-#X`k+32CVlLD7!hPBFH+M% zRpb1&4I@KMueS5-fne>OXEvG`;j*7R`Y&;YWqtlw<(3(yY!fqN;Y-~UEll>f)gJ4f z{DUb;li)&ADZIn zq2I1Rbz23@*;cMf<>xN7(s->mS<(s?LxcS`c?F3Zq#dshm5~l%I&0`B0cZohu_r-?~2SJRlSY9Q80|BMLd`PF{ z1U0K};YAns;-UXw%ES~R++E&HPvx5Qzl1>2ET2 zBkPZP`?=>131Uz?R>b{6fRsbbOiTLLR2Kdx;)#!1H8F@LjYQEnGz@RAbBnLUlqg88 z>O#uXl)sy|5xDn~TOL96jdCwIX#|$pi7pl^JP^Gl*DFc7yC67q2B*Mu{s!oHwXCVJ zGv|7B6qnENVf$`G^(ISq#kTQ6c>eI)1hvjp6XN?)qW~m9lnu|UN7*P6o~g7RhSrJ1 ze=2qnIZpa3>O9?RS`rSUH25GFt>=(z+|u1VlFe(%lDi>t6p|ib^N7tu$n63TlX%fQ z+~JRKaX{kn%YCRea62Q-`-pU3l3w1r5zm2Q;*~I3bNDa%aqE8dZh6c76f3Xj&#~9* zDJrQgj4j-Txqvrk>d=+z5F=xHr2`MLEq`t*2`|3Z+yQAogDX~FXo`J+uT=*?WU{VeM6>>$hHp%bsYSjqfS`N-85&_7l zwf*FBzj}YXTnJSu2mOwJMs-DPo4G?vcrZk`DG*S-dM;o{%0vIA+sR{999|Xwko-3J z`&hr;gNPHbN*>TWYVMBSFP@y0TAJ^>+I!a02&=8t4_{4`O3mV=T2p=`&TnHs<;;__ zD`W3asHIQ77gvyy5&T>b{cL?hm2=v^xbi&ZS6NV_0uhEX4}kecWcZhJh0Ur zj|Urix+gWR28eEnb_3m@b_r3Ca{+fGPXQ0q3BZEv@k)D{@ZA93c12f{Z#h@Zv6#U2 z?&6k_NG494sJ;4O2ks-0B`-XdKRTCw2mqqh~~fqC8OYW2H!(jR-30B~(n-HNWPrsUg2#J?clh5JA}Ci*+3 zcU;>nUB_JW9l$nMBV%f6ziG8`i8@TScFwLkkgh*&)YIhP3;dd49%8VtSbvPvj-ycC zd^+H!g@tM^g;P2J_G_zRGJ|jcFS)&~2Or3mcNYRiSWPo2ulwEraIF9ODWZze+kTFg zyzm`WW`yD~K8Kl0_%e1~yx1RXMldG@A6l4@wJ-&A($w9=)U@NZC0bPRZ;lIVd+wHd zk4KyY47`%c3cPHaqfW}e8T16YKQ5RL+I2r|pI1zD^gS{4o3z@{4G#~4I|3)oH8&TQ zh!x-=*EYUsEjSfQ>>g;R3AHQOEE^k*H2!Th&nBv98|awOvvd2Ap+opqQ1Qi}IDg%x zV%CVntI%p|v}~hDM-c`L`|uv~E=EKQVoID{lG?^K<3ec!!IAI$u_~asAG{#_*|Y9| zYeFE+X(v1)?~A2vz{V{jTq^p9jC_kZiLNBUBAr1^-y35GOu@Z^DmI+0tUtA z;9oQnYZS)Mecx|IM6{L!&KXUj)3e*#Med8RZi-`!kK5tQ*J7CR;`bZ$Wir{I9s<3T z2qV0cTbC-CDge*eab$`TPxiTPB8qP}t|Af@CcAW0e zFO-%^7&U_&IjU1=00I}aq8Ab2$&y}R=cJoTyaY3bTvp-)RE(P~!NGgS4F-4a4cgE< zpw)S;l*K}xpo?l!?gYHQFWu7?c%GwhkLU!gap>BIKw)zR|_-x`OV zY_pzazfSvuyv#>D9aOq(@#2!Vb&PhpTAX8RW8=+XO|Iumo2~RYN#plWN$(;Z;NjW@ z884O)Y9zFHLT@9q0aQho39+=i1|%kyI^HeNT*w3s(|Jp8Pr*k#l+R#3;;@$pjWJxJ}Yd}CdH>V?uZeq zWKqh#e{_!a_VyA9aUGAo0s$gkk(UrI@;BNe1>aw;+fT=#4;7&^xJWQT^6z2lXH?6T zE}FjhF4j6WWhI42yl!FaGTU2;jF7SWRSev+h}4$j>DlH$wpCU3+#=^NKg>+F!3=A> z^8!^wk0q0yw1)}j!Vcx;B@0m?9`jvq+3%Zb9`B&6EM@853;)RRsa zm<}Rtvt>+~uovqb(YJZyNkT+n9d-r>2dBPspZx2odUmdJo8pud3EeO&{vJrAWyt>G zFGuD)ezPoZLQuifvXZkjUwGfbFI}2oVxmN+`Zr=f!rESfmeX^r{BS=HAGIQfVlT=a zb_1FIYe!xSYYSj9dd!%Y6b@qGAfkM2_b+yDyJLj3S*NfpZsio8N5?KjMct*JBQ>_| znYs6)bT7^poV>`}zI^h_x2SjdIolD?8ChGXmVV|f#Ot3t<7;TggeD=MfU-wEAh^QC zL#`rSM|wT+Wo=b;HRHF~AIq074=rfygn!^=>h6u-t`_)|Sm9*C{*uB@Me-G>?SNhN zmi+>wO{2T7ExQ@?{X5K!5Hb>hej zclEyF@oibpPe0sArxV_?Wy^pe-xy1MAy!%D)^WzdQJ~d*`}GqafbHthle)>(03nH) zlou|nAZmvsPI>4=Q+2(LOzs#s@D2{jz+f$Ig3v^Fk^y>vr4yj4&8QW;q6ZEfVD!4Y z=$S1oSF)})eoS2ufZ{*lS_lkIK|x|sqjCg^yBUzzf*s}P)cJL0J`pgK(Cd@Ne|eqU zVxZw^KY_nJdD5O7#+~wmFS+gZVWL7Eq{%d|Khk2K7-O@-; zViLzthlB)1bf6&a0F1+gIolfs#c=waxVnwX6AZNi+Nk?wJS(!bv%8<= zU|zWOuq~1uNB3c*jm~`3Oym4HyVXx`&K<;VLkb;q`$bJL2PqQ-&WK6wHVg2&v6mLZ(+-kS6K3ZQE9x zq0d6vV%I%zzJFNH-ZK%jpRbp_KR;mYUM&&m{6xje(NS*T#FQ|v5AV;PODT3gCjD2;;*{C)_fv5_@QoXv?D9v&`689ns#oR7Z0m!4inm{~ZOV~fUGE``-N91^0W z?4tm;;yY~c;$_Qn44y7$&{2o8lULXL3OR!&?2z)4GOvP4OQw!ZJT^_Vg+u;yzW+yk z&F}NF+rMc6{{0nRxLDJ=@(RrfJ}pHFhQ@_m)YR<1`~>_IDo>HfNIa5MJ?YFDIXFCe zD%zTK96R~$9ugwVncm!*!j(FL6Fq;MvLm?8J%AL6L?o{?c-%^ioNVE3nQMQAD+;ew zdx8tI5(xu|0$CJB8ZJcrFF%)g)^`+PKf--ff>c$6O_0qQ`OCw_#YGe$=#hm{Ir3M7 zL=2k}f17`tM$lOB!CG)AYw%v-F8UlO(^O;OyngbKiYt(`oDK+Sh%5E%-+>vrgDTLZ4LQCtz&-Zmz1`tAW9| zzjlh-_8Y8xXKu5{4IMkY`#B)=%{7GI$Rv?->_9}h^jyU)4D|$`SPo5lv#~aN?8k&3 zOIWC4f{~UqxREGYuvuAe@nhF29huozToJ{MXdb?+QmK#Z#E`_X5jXCOxp1 zQjlhS<0S9Gs8NCKLUCYheFah`CeaJ69GJK{LU&|%OJp_&s~J&6}`Z@q2< z-Z+gWdv>b5{UE_3a{aWnJKMgX*%#qrWG49afUlG*{Gw11_m`FXyMDff^YXaIpl^ef z$82V$iO31bmU7=wtNw;*_@CRr{4=F0CqChgSWX`}GXO>ZG*k(ZtB)l|*yYGtxW&b- z>@>~Mai|D?)jUay^k&y19KzhOn|Gj>GwuW7V%-Of_aDup-N=)ti3l`_neqaP! zzU?~qO5Ele%OG9OQf`k7(q%+4F78Nc>SW>2pC3K;HjxPuFN~|tx+)VunpMbsv6FR> zUUhLD5`#fYeIYal3`u+GB;tNn;QSSVqD1b%hWm~mDowwKYjM>uKGzeqW!%n zeAAIFuTF%oRzR|9ZPSnMN62^=yOVt!RXE=`Ck5Ca?}9m?crIh?2%%6a>ah5={04y0?I%E3$e$K$t~?hmI*fI3XP_7Sj9 z#NqPnpAFnW-`F}w2bY^@LWSE|q=211t-`E#F>ioRG^+7e-|d|06A-rHf?L$>iJD3! z6p7|ZUteGGxl?NiTa)e^fP}0srOlf)(=P1_V(3k-2@{PD2&EUDb>K)xHAL28lHR+k zdB$uOpg<`#4MK6v7af%nXMQWBa{BH;5lo$RIO#c=2R~h*Ux%VR}Wvqk2*^B2(9F zCTb#ZtZPhGBE9jDWw&lVs;C(N&Ka`Jtc}}jwI@6vVI~3X)n*Ry$T#z+j!0T-R@ zm~|T@)kIEY74l)IMK9Yb7^diLpAseqedbv!sAbFoohexvMExMb0wr97sUE)RF&Dy? zjrw)Uj)@wnTzm}lX$5&a#4=%zX=H2r5uw3l*(Fa5Y?OU=0@t1ckt%xA2nfEPf4cf~ zK4XD(iK!6d1KGyDp7Qu}^7HeZA3k<_mlDL_RoFpcTMdGaX~`XJRBpUWgg;(5(;G}q@nQk@7?H} zA0{7IuYc%#Y;0_SKDK&69_#7p(GK20LMj9b)Vbm);8e-#;dX)w#tTOXErN*qhtPSX zSa|H^t5@=9-sr3p*cf7PbY8K8om~nAOi)RRd6dQD6~q4mUKF9{%msx$Sc%TJ#_%nn z-t@6X()>9~>3#H9wL6wjidZS9%E^0VV<>7_5kAMghwJ3kqfyV-nt5lw={;n~fuQPc zojPf{mhNS5qE?dk4S4zFw~-5z98-`ez4>5XKi{(Bf2SF=b9ESj@r$mEa)2k!FH=*~ zp~`b-2kB06?5PN{(!dn7VwbjB0sE0RUv0e8#CR|Pn(!<(b}n$QkrsX;1?9ooq%F0* zOP7XEL6IR$+9E95%zJpkU%A{EFy|sJQVg_2Uz?Jb7k_WZQ8tyFgma-_?P=1W(fW&Q z%4g1T%kRibW$^&3VhFCbw%6Xhtr(cTg2Ah*s;a;cX>@udYtMBHSz)S|{O*jnA>a+e zmL&{DTKTV7Hx-VlEJ@5U&Q2WRB_CAAxqY0l50yDxChhR9>3!#y+z=2msfJ6E{OWj9 z32M0j98=KRi_xWE2PXenLmHp2(9CUxyK}yUm`DU|Dy5*tU>Mey8mYnrSK#oY#Y!XOEv>27o^2aItfD_N>Tp0H2TxqcF(e&e?%Wx9%B$1@po`P2~(*S2*6E*fky99Zws2t};{U+e|Y0$nEFD zA^CQ7w1`_i9y!$8KdPz=|M{!M@J9#RnaT>}-UYR9`C;EYOF4tTzkoBC+qv{8`SGkpn+v3=rpvz^>h zG-Dz;gaIGa%>tIXaO$b?(+NWqZ~cmsyWOhzTXj{vT4%YV*F#%4x~h-a-M)L{lmpLF zE1JF?_Z+juRlf727FEBupUrOZ1!PDJ0|jpis-fk*OMMq;THZ3r6SC{GOT9twhiQj* z?lw$FFtT-e6V@zAS$cDJPK2AmQ>7<0hYH?RQww}p!9ij?8kAP2x(Bu}@s9+8ojUF? zqr(_iQ_q)eB>_+RUORp!ucm#-C)=93NX>ys6S@Z({q7whS6^=#anieTtIreRP4i^zx5y=cAiG({3_VQqVzb=TGQtz@>AtHEu zr=ypr+r%`>QB!E}T(4#JsCXQoNPA z;Zf%oc(LN!OPx7%k<;Hn*&i(+PRO->*@%j9e)tzRNOgxs~ zE)45`vt9j-qb0SEN>Y9Dci9a7rIj-O_HCPzmRI^3k5)+WSg+nt<#tQk;oamm!k)`P zG4#V$=39z;`PD6$Glq=cEc(j2IvWU76&JdX&IC@>3!_V4#y3e#wUEp*zs}kv(B#`PU|n-I5>ws67y6N0DCu-ie*tERJ$LFDatGn$(Bmc@x4*uXX+__)q-gWLXZojEnsWaDX zV3Kb}^BchiO*hWAeq^(4&zl2!RgZrSidr~d^NwsWDw>Lp(w`c|v7=hdPV9cb&0d=p={GxF}> zO~oJDkIGwf^43qe9kNTBbz{>fzu!Fy)v8L5 zrhQq=pltCna7z@3v(56p2dO+5snLR2cDu{e9lFe?ML;{o|sDU&YFaua_TI>71;m zef2~;7vRE>`H#<4)GZ5+QTpj>p!Ml~$P9K!c;Z*JnF++J6sGM$NX|{rn(l+S5SIBUkg9Ox?rN zh0z&WLY;PLz|f)1C4?t-z@xpgp!D5)=Ru*UKK3FKxca?5Hu>ub%j$E3$)WY7i6k$1 zITcjv%)-LLGyoxpSo@}wB-rmV_v)d5<sRinglMt%958sXOFpEhyoZj=6E>!|m2?QqNHtI5F zV_#m7Hs(J^^QD8C6!I5}LXgdJCn&GM=>U5SnWi-kpjI%g-!CX0fQzKz@u*d`dVTill=DGLZey!$B(akb$2f* zmdphvN2CRDyl^)XT68KumRWOnEOAYNOzc1o?`XdGoOnR+0H{!*6g%={Gmsh!CmIpT zMBiO5>iGn`IF}ZaXR?B(bEDrc7*Yk_3CFRtjUAgbQudfQBxo<)l8DGiISH{_sWj6k zk~L|R#%=vwA9@F;U^$ZBjXAYgk*tAqHhvTH#YGvCmH6;sg%!Y^>}f ziocED<^*cXvDTv1cZ!iocj3GlPb9)whYVW_5KDNms5-cM&U@KJ(%EC5NXg7>gOp-p zp^qWr4tWIyJS&Q@H6fMN3kuL;wD^~ajt!-#w%Ah)q6D%LDv2e&(}ym)(oCYg;nn2p z6I45Q_JLy*#yVhK{5{jatf@xAHum6n%7sVzALX6>JJo3($G?ZETxG~6u0gAZYr3?% zrv^!B#+n!{VRpvTjE8iD@*LSn;g~Xwp_$qur&6XurBM?xXj5#g$Hc5S!W^asiDufV znO5xUp8awEggrmHxSVso-|u~Y?)RfA8X}Qr)0w&2fBdgw=#9$kL?J+1b0D)d(6nz) z=+37P-Gw$&K4H3k*QHiQg&#)L-qTwPDb~_PsSwu3-GW~=o2jnC&-lla<^IbsV4yNM z)GIOqIk-3y9LS*DmTGQinyb7&B{F{DYy!erv2iKC3$F$Yhfli{Fiz$urP*;~nmKvDuFBBNp-=_3k^wVXYC zt4~3t##BV3k`x#?Vj@wknK2WKoHwaM+M|YbU(R2+i7<68lYyYsNuI@~)J#Yn_H~g6cwJK8qv-Qk$_(mV@;W9%X zX^>TZy+flVZK20jD9u{i+7yspGtctbkrc-k`==+1a{Sk*@U~=;prcAgh@Af?uy%S64lQ zA}*6C$a5S$L{Kgi>pu-3eE(bgcO)gX#6emgJ(5v7!)h5uPYTA?!@eUV53XulZn z3ZhyV-bH6AtcvgG@B>H*vhecO#s!xI{>B}%mLXaBH_@JkR`m7u3L_b?rR(Y13!o0d zUcq@>kl|gH3B7PqdAUClK%s1ClD3;kF)q7zSGiTSyA^A_m|QkMHcryy-<~TQe46nJ z2?qgk>ZX)Hw7%;8rNQeR9UVc?@sXw~$zBzG#9%z=LEcF8Yd>yN());H0Q!AD!RQ{$ z-3toffQExR)^AD$9;PM1?Bq$tb2H9g_MO_{%PoTLQ^Q5ax}>E22jMUd+-ZFVp$66( z1L70WJ>A4dDvSaHd!M@FPqcYdIbFl@s3nQM@1fN2P2EjRnJU-kyssg4DbDxxY$Xcb zhcN4k1qNXUJnvHLv}7D)h(BgYG?|GO5BaG_a2qVuZ^KKFw!d&XVDoD8)Sne)vb`MT z3x3#F@j6eX)G_LRr=w80QZ5g*SNI>nCd~(L>NhttyB|DQT-r;#J%3F)Yo<`&jB}2k zk4SEC+=g#1c;)Y5eMR`s>H+7zqY2e&G3O0b6<)rqk2$~Ur<#<1uVI}`Z&+60={aSYga&fypSdLZd`FqJ$Bq%GRb$Wj)7C@4%>5GH@>mMp|q|cz%ym zUHzaU>NTS1V9L`G#A$MTH!D6le z)lYYjA|oFeeIJmx#%@6e+7P!O?>*NoY)eK)M@1~2j-H+~P|MtT^F%fjmbAi&I+W!; zJ?_{#mRIVsKpNU<#sXol| ztUaF0gRY2(_>`r_vQOA{`N}JQx&CFpM?Ud{%q22;tBuchoU9Qv#6CO4AubCrjIBDD zvB;{-*YI$=RyF#tvXsszJ`dvkOxnZ4!}Hed6geJ8Q=_*T&z~=8Yip~bJ7xZ%;Lc0z z>`cJ;;e~FqC2J7;F(9JDxOMlg0UTsRY1OU~HwRlA6dO4~Wkg$(S)KT&lpelik3#=o z_O1H;U=)3}TwXd^rs)nS!Jy zsEitJ-@;*4ULLNeS2>o#?>M3oPI~jVf+&Lz!~jvLj*lIyoq4%Rh)!uTL64^3gI@Y@ z_}nsnL!UVy>Z`#Xn&Cm0+CHkW5jKAPKTavp)m!%acRuDq{LcOR5Nr066`>Q&pmG-0 zc~8%b65KP8H2iADgLY!C5oEHdT>Cl}qRaKP=X>{No6NB&3^YhPh8_dgp-@xB-y1sg z%ySzWA6k<18IhPVKgZ7A{8s5w{t1+0n9(;wPk%ww#}Y>xzgLgk3ym6`bx2)gqI@i! z!9L&oP_DW*tycZ{ItB}Y5%X~z8QcYLPjXZK3)NWr`tVz%ks9XG^_8N~R`>S@YX019 z9V1CvTV0&q_8Lpu9uVr5?0IL&5rM;54%+EtP03T`veqL zG#yf~lS%1=Axg0KFP@TH+S)dX2r?nKp*)Q#kEeqH$minB&(K3oY94 z2pM+b{e#)-TazX8#MM{t3#UoXuy)7wa!MH%yOr zeM?KpKld1U@f^&xwXO81EWhG0JE3d$;d3uh1t}O_7c4lkYtc5y`PTR^qC?rh#h!{5 zIp$uRpA~`#r010-^Ug3Ni$HP0FTR<0@n5pFsK0SEyU0x?uIl;6Nt`^^#trRPe^$K0 yEc0r~6-29n4f$Ur@&Dl`)8_9k>K+=;+T14ba(TzYsdU4ZkNyL*Bs_Eg literal 0 HcmV?d00001 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5b266a26..f3191930 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ A11000000000000000000001 /* AlarmLaunchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11000000000000000000002 /* AlarmLaunchPayload.swift */; }; A02000000000000000000001 /* BackupExportPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000000000000000002 /* BackupExportPlugin.swift */; }; + D05000000000000000000001 /* BackupImportPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05000000000000000000002 /* BackupImportPlugin.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 2C6F05C62DFD060A00A9E3A1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6F05C52DFD060A00A9E3A1 /* SceneDelegate.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; @@ -48,6 +49,7 @@ /* Begin PBXFileReference section */ A11000000000000000000002 /* AlarmLaunchPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmLaunchPayload.swift; sourceTree = ""; }; A02000000000000000000002 /* BackupExportPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupExportPlugin.swift; sourceTree = ""; }; + D05000000000000000000002 /* BackupImportPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupImportPlugin.swift; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 175B7EB22D7A844500737EBA /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; }; @@ -156,6 +158,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, A02000000000000000000002 /* BackupExportPlugin.swift */, + D05000000000000000000002 /* BackupImportPlugin.swift */, A11000000000000000000002 /* AlarmLaunchPayload.swift */, 2C6F05C52DFD060A00A9E3A1 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -406,6 +409,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, A02000000000000000000001 /* BackupExportPlugin.swift in Sources */, + D05000000000000000000001 /* BackupImportPlugin.swift in Sources */, A11000000000000000000001 /* AlarmLaunchPayload.swift in Sources */, 2C6F05C62DFD060A00A9E3A1 /* SceneDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, diff --git a/ios/Runner/AlarmLaunchPayload.swift b/ios/Runner/AlarmLaunchPayload.swift index e6b1d20c..6297a393 100644 --- a/ios/Runner/AlarmLaunchPayload.swift +++ b/ios/Runner/AlarmLaunchPayload.swift @@ -9,12 +9,18 @@ enum AlarmLaunchPayload { !id.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return nil } - return [ + let identity = source?["storeIncarnation"] as? String + if source?["storeIncarnation"] != nil { + guard let identity, identity.range(of: "^[a-fA-F0-9-]{32,36}$", options: .regularExpression) != nil else { return nil } + } + var result = [ "type": "schedule_alarm", "scheduleId": id, - "alarmLaunchPayloadVersion": "9", + "alarmLaunchPayloadVersion": "10", "promptVariant": "alarm" ] + if let identity { result["storeIncarnation"] = identity } + return result } /// Rewrites old app-owned pending data even when DB startup subsequently fails. diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 46e9c40b..5fd864e5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -13,6 +13,7 @@ private let onTimeAlarmLaunchURLHost = "alarm" @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private static weak var current: AppDelegate? + private static let processIdentity = UUID().uuidString.lowercased() private var nativeAlarmChannel: FlutterMethodChannel? override func application( @@ -31,6 +32,9 @@ private let onTimeAlarmLaunchURLHost = "alarm" func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "OnTimeBackupImport") { + BackupImportPlugin.register(with: registrar) + } if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "OnTimeBackupExport") { BackupExportPlugin.register(with: registrar) } @@ -46,6 +50,8 @@ private let onTimeAlarmLaunchURLHost = "alarm" private func handleNativeAlarmCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { + case "getProcessIdentity": + result(Self.processIdentity) case "getCapabilities": result(nativeAlarmCapabilities()) case "checkPermission": @@ -380,6 +386,9 @@ private let onTimeAlarmLaunchURLHost = "alarm" // Duplicate identity parameters are ambiguous, not last-value-wins. let identities = (components.queryItems ?? []).filter { $0.name == "scheduleId" } if identities.count == 1 { values["scheduleId"] = identities.first?.value } + let incarnations = (components.queryItems ?? []).filter { $0.name == "storeIncarnation" } + if incarnations.count > 1 { return true } + if incarnations.count == 1 { values["storeIncarnation"] = incarnations.first?.value } } guard let payload = AlarmLaunchPayload.sanitize(values) else { UserDefaults.standard.removeObject(forKey: onTimeAlarmLaunchPayloadDefaultsKey) diff --git a/ios/Runner/BackupExportPlugin.swift b/ios/Runner/BackupExportPlugin.swift index 863a723e..3635944a 100644 --- a/ios/Runner/BackupExportPlugin.swift +++ b/ios/Runner/BackupExportPlugin.swift @@ -1,26 +1,46 @@ import Flutter import UIKit +import CryptoKit +import Darwin -/// Only encrypted containers cross this channel; external URLs are never persisted. +/// Only bounded encrypted chunks cross the channel. Provider URLs stay native. final class BackupExportPlugin: NSObject, FlutterPlugin, UIDocumentPickerDelegate, UIAdaptivePresentationControllerDelegate { private static let io = DispatchQueue(label: "ontime.backup-export.io", qos: .utility) - private static var liveDirectories = Set() // Access only on io. + private static var liveDirectories = Set() // io queue only + private static let limit: Int64 = 72 * 1024 * 1024 private var active: Attempt? + private var lastReceipt: (String, [String: Any])? private final class Attempt { - let id = UUID() - let result: FlutterResult + let id: String let directory: URL let file: URL - var finishing = false - weak var picker: UIDocumentPickerViewController? + private let lock = NSLock() + private var interrupted = false + var cancelled: Bool { + lock.lock(); defer { lock.unlock() }; return interrupted + } + func cancel() { lock.lock(); interrupted = true; lock.unlock() } + var busy = false + var cleaning = false + var directoryOwned = false + var openHandles: [FileHandle] = [] // io queue; retain every unconfirmed close + var registered = false + var initialized = false + var sealed = false + var length: Int64 = 0 + var sequence: Int64 = 0 + var outcome: String? + var providerTerminal = true // UI dismissal alone does not end a provider copy + var pickerPending = false + var pickerClosing = false + var exportResult: FlutterResult? + var cancelResults: [FlutterResult] = [] + var picker: UIDocumentPickerViewController? weak var scene: UIWindowScene? - - init(result: @escaping FlutterResult, directory: URL, file: URL) { - self.result = result - self.directory = directory - self.file = file + init(id: String, directory: URL, file: URL) { + self.id = id; self.directory = directory; self.file = file } } @@ -33,141 +53,273 @@ final class BackupExportPlugin: NSObject, FlutterPlugin, UIDocumentPickerDelegat override init() { super.init() NotificationCenter.default.addObserver(self, selector: #selector(sceneDisconnected(_:)), - name: UIScene.didDisconnectNotification, object: nil) + name: UIScene.didDisconnectNotification, object: nil) Self.io.async { guard let root = try? Self.exportRoot(), - let children = try? FileManager.default.contentsOfDirectory( - at: root, includingPropertiesForKeys: nil) else { return } + let children = try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + else { return } for child in children where !Self.liveDirectories.contains(child) { try? FileManager.default.removeItem(at: child) } } } - deinit { NotificationCenter.default.removeObserver(self) } - + func detachFromEngine(for registrar: FlutterPluginRegistrar) { + if let attempt = active { cancel(attempt, result: nil) } + } @objc private func sceneDisconnected(_ notification: Notification) { guard let attempt = active, let scene = notification.object as? UIWindowScene, - attempt.scene === scene else { return } - finish(attempt, failed: true) + attempt.scene === scene else { return } + cancel(attempt, result: nil) } func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard call.method == "export" else { result(FlutterMethodNotImplemented); return } - guard active == nil else { - result(FlutterError(code: "export_busy", message: "A backup export is already in progress.", details: nil)) + let args = call.arguments as? [String: Any] + if call.method == "begin" { + guard active == nil else { result(Self.failure("export_busy")); return } + guard let name = args?["suggestedName"] as? String, !name.isEmpty, + name.utf16.count <= 200, name.hasSuffix(".ontimebackup"), + !name.contains("/"), !name.contains("\\"), !name.contains("\0"), + let root = try? Self.exportRoot() else { result(Self.failure("export_invalid")); return } + let id = UUID().uuidString + let directory = root.appendingPathComponent(id, isDirectory: true) + active = Attempt(id: id, directory: directory, file: directory.appendingPathComponent(name)) + result(id) return } - guard let args = call.arguments as? [String: Any], - let bytes = args["encryptedBytes"] as? FlutterStandardTypedData, - !bytes.data.isEmpty, - let name = args["suggestedName"] as? String, - !name.isEmpty, name.count <= 200, name.hasSuffix(".ontimebackup"), - !name.contains("/"), !name.contains("\\") else { - result(Self.failure()) - return + let id = args?["handle"] as? String + if call.method == "cancel", let id, let last = lastReceipt, id == last.0 { + result(last.1); return } - // Resolve only our own cache location here; actual disk work runs off the UI thread. - guard let root = try? Self.exportRoot() else { result(Self.failure()); return } - let directory = root.appendingPathComponent(UUID().uuidString, isDirectory: true) - let attempt = Attempt(result: result, directory: directory, file: directory.appendingPathComponent(name)) - active = attempt + guard let attempt = active, id == attempt.id else { result(Self.failure("export_invalid")); return } + if call.method == "cancel" { cancel(attempt, result: result); return } + guard !attempt.cancelled, attempt.outcome == nil else { result(Self.failure("export_cancelled")); return } + guard !attempt.busy, !attempt.cleaning, !attempt.pickerPending else { + result(Self.failure("export_busy")); return + } + switch call.method { + case "append": + guard !attempt.sealed, let data = args?["bytes"] as? FlutterStandardTypedData, + (1...65536).contains(data.data.count), + let sequence = Self.integer(args?["sequence"]), sequence == attempt.sequence else { + result(Self.failure("export_invalid")); return + } + guard Int64(data.data.count) <= Self.limit - attempt.length else { + result(Self.failure("export_limit")); return + } + work(attempt, result: result) { + try Self.checkCancelled(attempt) + if !attempt.initialized { + guard Self.liveDirectories.insert(attempt.directory).inserted else { throw ExportError.io } + attempt.registered = true + try FileManager.default.createDirectory(at: attempt.directory.deletingLastPathComponent(), + withIntermediateDirectories: true) + // Atomic exclusive creation: a colliding directory is never ours to remove. + guard Darwin.mkdir(attempt.directory.path, 0o700) == 0 else { throw ExportError.io } + attempt.directoryOwned = true + var excluded = attempt.directory + var values = URLResourceValues() + values.isExcludedFromBackup = true + try excluded.setResourceValues(values) + guard FileManager.default.createFile(atPath: attempt.file.path, contents: nil, + attributes: [.protectionKey: FileProtectionType.complete]) else { throw ExportError.io } + attempt.initialized = true + } + try Self.withHandle(attempt, try FileHandle(forWritingTo: attempt.file)) { handle in + try handle.seekToEnd() + try handle.write(contentsOf: data.data) + } + attempt.length += Int64(data.data.count) + attempt.sequence += 1 + } + case "seal": + guard !attempt.sealed, attempt.initialized, + let length = Self.integer(args?["length"]), (1...Self.limit).contains(length), length == attempt.length, + let digest = args?["sha256"] as? String, digest.utf8.count == 64, + digest.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { + result(Self.failure("export_invalid")); return + } + work(attempt, result: result, sealed: true) { + try Self.withHandle(attempt, try FileHandle(forWritingTo: attempt.file)) { try $0.synchronize() } + var hash = SHA256() + var read: Int64 = 0 + try Self.withHandle(attempt, try FileHandle(forReadingFrom: attempt.file)) { reader in + while true { + try Self.checkCancelled(attempt) + let chunk = try reader.read(upToCount: 65536) ?? Data() + if chunk.isEmpty { break } + guard Int64(chunk.count) <= length - read else { throw ExportError.io } + hash.update(data: chunk) + read += Int64(chunk.count) + } + } + let actual = hash.finalize().map { String(format: "%02x", $0) }.joined() + guard read == length, actual == digest else { throw ExportError.io } + } + case "export": + guard attempt.sealed, attempt.exportResult == nil else { result(Self.failure("export_invalid")); return } + attempt.exportResult = result + present(attempt) + default: result(FlutterMethodNotImplemented) + } + } + + private func work(_ attempt: Attempt, result: @escaping FlutterResult, sealed: Bool = false, + block: @escaping () throws -> Void) { + attempt.busy = true Self.io.async { - do { - Self.liveDirectories.insert(directory) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - var excluded = directory - var values = URLResourceValues() - values.isExcludedFromBackup = true - try excluded.setResourceValues(values) - try bytes.data.write(to: attempt.file, options: [.atomic, .completeFileProtection]) - let handle = try FileHandle(forWritingTo: attempt.file) - do { - try handle.synchronize() - try handle.close() - } catch { - try? handle.close() - throw error + var failed = false + do { try block() } catch { failed = true } + DispatchQueue.main.async { + attempt.busy = false + if failed || attempt.cancelled { + if !attempt.cancelled { attempt.outcome = "failed" } + result(Self.failure(attempt.cancelled ? "export_cancelled" : "export_io")) + } else { + if sealed { attempt.sealed = true } + result(true) } - guard try Data(contentsOf: attempt.file) == bytes.data else { throw ExportError.invalidContainer } - DispatchQueue.main.async { self.present(attempt) } - } catch { - DispatchQueue.main.async { self.finish(attempt, failed: true) } + self.finish(attempt) } } } private func present(_ attempt: Attempt) { - guard active === attempt, !attempt.finishing else { return } - guard let scene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) + guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }), var presenter = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { - finish(attempt, failed: true) - return + attempt.outcome = "failed"; finish(attempt); return } while let presented = presenter.presentedViewController { presenter = presented } guard presenter.viewIfLoaded?.window != nil, !presenter.isBeingDismissed else { - finish(attempt, failed: true) - return + attempt.outcome = "failed"; finish(attempt); return } let picker = UIDocumentPickerViewController(forExporting: [attempt.file], asCopy: true) picker.delegate = self - picker.presentationController?.delegate = self attempt.picker = picker attempt.scene = scene + attempt.pickerPending = true + attempt.providerTerminal = false presenter.present(picker, animated: true) { picker.presentationController?.delegate = self - if picker.presentingViewController == nil { self.finish(attempt, failed: true) } - } - // UIKit may reject presentation without invoking its completion closure. - DispatchQueue.main.async { - if self.active === attempt, picker.presentingViewController == nil { - self.finish(attempt, failed: true) - } } + // UIKit presentation is asynchronous. A missing presentingViewController + // on the next main turn (or after UI dismissal) is not a provider terminal + // receipt. Only the document delegate may authorize spool cleanup once the + // picker has been handed the file; never infer it from UI timing. } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let attempt = active, attempt.picker === controller else { return } - finish(attempt, outcome: "saved", failed: urls.count != 1) + // The system reports a completed copy. Preserve it through cancellation/cleanup. + attempt.providerTerminal = true + if attempt.outcome != "saved" { attempt.outcome = urls.count == 1 ? "saved" : "failed" } + dismiss(attempt) } - func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { guard let attempt = active, attempt.picker === controller else { return } - finish(attempt, outcome: "cancelled") + attempt.providerTerminal = true + if attempt.outcome != "saved" { attempt.outcome = "cancelled" } + dismiss(attempt) } - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - guard let attempt = active, - attempt.picker === presentationController.presentedViewController else { return } - finish(attempt, outcome: "cancelled") + guard let attempt = active, attempt.picker === presentationController.presentedViewController else { return } + if attempt.outcome == nil { attempt.outcome = "cancelled" } + pickerTerminated(attempt) + } + + private func cancel(_ attempt: Attempt, result: FlutterResult?) { + if let result { attempt.cancelResults.append(result) } + attempt.cancel() + if attempt.outcome == nil { attempt.outcome = "cancelled" } + // UIKit exposes no documented provider-copy cancellation acknowledgement + // for a programmatic dismiss. Keep the system picker and spool until its + // save/cancel delegate actually terminates the operation. This is cooperative + // cancellation, never a timeout or scene teardown promoted to completion. + if attempt.pickerPending && attempt.providerTerminal { dismiss(attempt) } + finish(attempt) + } + private func dismiss(_ attempt: Attempt) { + guard attempt.providerTerminal, !attempt.pickerClosing else { return } + attempt.pickerClosing = true + guard let picker = attempt.picker, picker.presentingViewController != nil else { + pickerTerminated(attempt); return + } + picker.dismiss(animated: false) { self.pickerTerminated(attempt) } + } + private func pickerTerminated(_ attempt: Attempt) { + attempt.pickerPending = false + attempt.pickerClosing = false + if attempt.providerTerminal { attempt.picker = nil } + finish(attempt) } - private func finish(_ attempt: Attempt, outcome: String = "cancelled", failed: Bool = false) { - guard active === attempt, !attempt.finishing else { return } - attempt.finishing = true + private func finish(_ attempt: Attempt) { + guard active === attempt, attempt.outcome != nil, !attempt.busy, + attempt.providerTerminal, !attempt.pickerPending, !attempt.cleaning else { return } + guard attempt.exportResult != nil || !attempt.cancelResults.isEmpty || attempt.cancelled else { return } + attempt.cleaning = true Self.io.async { - // If removal fails, startup retries. A completed external copy remains a saved backup. - try? FileManager.default.removeItem(at: attempt.directory) - Self.liveDirectories.remove(attempt.directory) + var cleaned = false + do { + for handle in attempt.openHandles { + do { + try handle.close() + attempt.openHandles.removeAll { $0 === handle } + } catch { } + } + guard attempt.openHandles.isEmpty else { throw ExportError.io } + if attempt.directoryOwned, FileManager.default.fileExists(atPath: attempt.directory.path) { + try FileManager.default.removeItem(at: attempt.directory) + } + cleaned = !attempt.directoryOwned || !FileManager.default.fileExists(atPath: attempt.directory.path) + } catch { } + if cleaned && attempt.registered { Self.liveDirectories.remove(attempt.directory) } DispatchQueue.main.async { - guard self.active === attempt else { return } - self.active = nil - attempt.result(failed ? Self.failure() : outcome) + attempt.cleaning = false + let receipt: [String: Any] = ["outcome": attempt.outcome ?? "cancelled", "cleanupUnconfirmed": !cleaned] + let replies = attempt.cancelResults + attempt.cancelResults.removeAll() + let exportReply = attempt.exportResult + attempt.exportResult = nil + if cleaned { + self.lastReceipt = (attempt.id, receipt) + self.active = nil + } + exportReply?(receipt) + replies.forEach { $0(receipt) } } } } + private static func withHandle(_ attempt: Attempt, _ handle: FileHandle, + block: (FileHandle) throws -> Void) throws { + attempt.openHandles.append(handle) + do { + try block(handle) + try handle.close() + attempt.openHandles.removeAll { $0 === handle } + } catch { + // Keep ownership unless a retry actually confirms closure. + do { try handle.close(); attempt.openHandles.removeAll { $0 === handle } } catch { } + throw error + } + } + private static func integer(_ value: Any?) -> Int64? { + guard let number = value as? NSNumber, + ["q", "i", "s", "l", "Q", "I"].contains(String(cString: number.objCType)), + number.doubleValue >= 0, number.doubleValue <= Double(Int64.max) else { return nil } + return number.int64Value + } + private static func checkCancelled(_ attempt: Attempt) throws { + if attempt.cancelled { throw ExportError.io } + } private static func exportRoot() throws -> URL { - try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, - appropriateFor: nil, create: false) + try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false) .appendingPathComponent("OnTimeBackupExports", isDirectory: true) } - - private static func failure() -> FlutterError { - FlutterError(code: "export_failed", message: "Backup could not be saved. Please try again.", details: nil) + private static func failure(_ code: String) -> FlutterError { + FlutterError(code: code, message: "Backup export did not complete. A partial file may remain at the selected location.", details: nil) } - - private enum ExportError: Error { case invalidContainer } + private enum ExportError: Error { case io } } diff --git a/ios/Runner/BackupImportPlugin.swift b/ios/Runner/BackupImportPlugin.swift new file mode 100644 index 00000000..af1bf032 --- /dev/null +++ b/ios/Runner/BackupImportPlugin.swift @@ -0,0 +1,283 @@ +import Flutter +import UIKit +import UniformTypeIdentifiers + +/// Backup-only pull reader. All provider reads remain inside one coordinated +/// accessor, with transient security scope owned until the actual worker exits. +final class BackupImportPlugin: NSObject, FlutterPlugin, UIDocumentPickerDelegate, + UIAdaptivePresentationControllerDelegate { + private var active: Attempt? + private var lastClosed: String? + private static let limit: Int64 = 72 * 1024 * 1024 + + private final class Attempt { + let id = UUID().uuidString + let condition = NSCondition() + var cancelled = false // condition-protected, including during a blocked read + var request: (Int, FlutterResult)? + var reading = false + var started = false + var terminal = false + var closeGeneration = 0 + var cleanupFailed = false + var picked = false + var pickerTerminal = true // main-thread receipt, separate from worker terminal + var pickerClosing = false + var pickResult: FlutterResult? // main-thread only + var closeResult: FlutterResult? // main-thread only + weak var picker: UIDocumentPickerViewController? + weak var scene: UIWindowScene? + } + + static func register(with registrar: FlutterPluginRegistrar) { + let plugin = BackupImportPlugin() + let channel = FlutterMethodChannel(name: "ontime/backup_import", binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(plugin, channel: channel) + } + + override init() { + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(sceneDisconnected(_:)), + name: UIScene.didDisconnectNotification, object: nil) + } + deinit { NotificationCenter.default.removeObserver(self) } + + func detachFromEngine(for registrar: FlutterPluginRegistrar) { + if let attempt = active { close(attempt, result: nil) } + } + + @objc private func sceneDisconnected(_ notification: Notification) { + guard let attempt = active, let scene = notification.object as? UIWindowScene, + attempt.scene === scene else { return } + close(attempt, result: nil) + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "begin" { + guard active == nil else { result(Self.failure("import_busy")); return } + let attempt = Attempt() + active = attempt + result(attempt.id) + return + } + let args = call.arguments as? [String: Any] + let id = args?["handle"] as? String + if call.method == "close", let id, id == lastClosed { result(true); return } + guard let attempt = active, id == attempt.id else { result(Self.failure()); return } + switch call.method { + case "pick": + guard !attempt.picked else { result(Self.failure()); return } + attempt.picked = true + attempt.pickResult = result + present(attempt) + case "read": + guard let number = args?["maxBytes"] as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + ["q", "i", "s", "l", "Q", "I"].contains(String(cString: number.objCType)), + number.int64Value >= 1, number.int64Value <= 65536 else { + result(Self.failure()); return + } + attempt.condition.lock() + guard attempt.started, !attempt.cancelled, !attempt.terminal, + !attempt.reading, attempt.request == nil else { + attempt.condition.unlock(); result(Self.failure()); return + } + attempt.request = (number.intValue, result) + attempt.condition.signal() + attempt.condition.unlock() + case "close": close(attempt, result: result) + default: result(FlutterMethodNotImplemented) + } + } + + private func present(_ attempt: Attempt) { + guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + var presenter = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { + finishPick(attempt, value: Self.failure()); return + } + while let presented = presenter.presentedViewController { presenter = presented } + guard presenter.viewIfLoaded?.window != nil, !presenter.isBeingDismissed else { + finishPick(attempt, value: Self.failure()); return + } + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: false) + picker.allowsMultipleSelection = false + picker.delegate = self + attempt.picker = picker + attempt.pickerTerminal = false + attempt.scene = scene + presenter.present(picker, animated: true) { picker.presentationController?.delegate = self } + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let attempt = active, attempt.picker === controller, urls.count == 1 else { return } + attempt.condition.lock() + guard !attempt.cancelled, !attempt.started else { attempt.condition.unlock(); return } + attempt.started = true + attempt.condition.unlock() + let url = urls[0] // Native-only, never persisted or returned over the channel. + DispatchQueue.global(qos: .utility).async { self.readCoordinated(attempt, url: url) } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + guard let attempt = active, attempt.picker === controller else { return } + finishPick(attempt, value: nil) + } + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard let picker = presentationController.presentedViewController as? UIDocumentPickerViewController else { return } + documentPickerWasCancelled(picker) + } + + private func readCoordinated(_ attempt: Attempt, url: URL) { + let acquired = url.startAccessingSecurityScopedResource() + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var entered = false + var workerError: FlutterError? + coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in + entered = true + var handle: FileHandle? + do { + attempt.condition.lock() + let cancelled = attempt.cancelled + attempt.condition.unlock() + if cancelled { throw ImportError.cancelled } + let values = try coordinatedURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + if values.isRegularFile == false { throw ImportError.io } + let length = values.fileSize.flatMap { $0 >= 0 ? Int64($0) : nil } + if let length, length > Self.limit { throw ImportError.limit } + let opened = try FileHandle(forReadingFrom: coordinatedURL) + handle = opened + DispatchQueue.main.async { self.finishPick(attempt, value: ["length": length as Any? ?? NSNull()]) } + var consumed: Int64 = 0 + var eof = false + while true { + attempt.condition.lock() + while !attempt.cancelled && attempt.request == nil { attempt.condition.wait() } + if attempt.cancelled { + let request = attempt.request + attempt.request = nil + attempt.condition.unlock() + if let request { Self.deliver(request.1, Self.failure("import_cancelled")) } + break + } + let request = attempt.request! + attempt.request = nil + attempt.reading = true + attempt.condition.unlock() + var response: Any + do { + let size = min(request.0, Int(Self.limit - consumed + 1)) + let data = eof ? Data() : (try opened.read(upToCount: size) ?? Data()) + if Int64(data.count) > Self.limit - consumed { throw ImportError.limit } + consumed += Int64(data.count) + eof = data.isEmpty + response = ["bytes": FlutterStandardTypedData(bytes: data), "eof": eof] + } catch ImportError.limit { response = Self.failure("import_limit") } + catch { response = Self.failure() } + attempt.condition.lock() + let cancelled = attempt.cancelled + attempt.reading = false + attempt.condition.unlock() + Self.deliver(request.1, cancelled ? Self.failure("import_cancelled") : response) + } + } catch ImportError.cancelled { workerError = Self.failure("import_cancelled") } + catch ImportError.limit { workerError = Self.failure("import_limit") } + catch { workerError = Self.failure() } + // Closing belongs to this accessor too. Never release coordination/scope + // on a timer while a provider operation has not returned. + var closed = false + while !closed { + attempt.condition.lock() + let generation = attempt.closeGeneration + attempt.condition.unlock() + do { try handle?.close(); closed = true } + catch { + attempt.condition.lock(); attempt.cleanupFailed = true; attempt.condition.unlock() + DispatchQueue.main.async { + let reply = attempt.closeResult + attempt.closeResult = nil + reply?(Self.failure()) + } + // Keep handle, coordinated accessor and security scope owned while a + // failed close is retried. No new import can replace this attempt. + attempt.condition.lock() + while attempt.closeGeneration == generation { attempt.condition.wait() } + attempt.cleanupFailed = false + attempt.condition.unlock() + } + } + } + if acquired { url.stopAccessingSecurityScopedResource() } + if !entered || coordinationError != nil { workerError = Self.failure() } + attempt.condition.lock() + attempt.terminal = true + let pending = attempt.request + attempt.request = nil + attempt.condition.unlock() + if let pending { Self.deliver(pending.1, workerError ?? Self.failure("import_cancelled")) } + let failure = workerError + DispatchQueue.main.async { + if let failure { self.finishPick(attempt, value: failure) } + self.finishClose(attempt) + } + } + + private func finishPick(_ attempt: Attempt, value: Any?) { + guard active === attempt else { return } + let callback = attempt.pickResult + attempt.pickResult = nil + attempt.condition.lock(); let cancelled = attempt.cancelled; attempt.condition.unlock() + callback?(cancelled ? Self.failure("import_cancelled") : value) + } + + private func close(_ attempt: Attempt, result: FlutterResult?) { + guard attempt.closeResult == nil else { result?(Self.failure("import_busy")); return } + attempt.closeResult = result + attempt.condition.lock() + attempt.cancelled = true + attempt.closeGeneration += 1 + let started = attempt.started + if !started { attempt.terminal = true } + attempt.condition.broadcast() // Independent of the blocked coordination/read worker. + attempt.condition.unlock() + finishPick(attempt, value: Self.failure("import_cancelled")) + if !attempt.pickerClosing { + attempt.pickerClosing = true + if let picker = attempt.picker, picker.presentingViewController != nil { + picker.dismiss(animated: true) { + attempt.pickerTerminal = true + self.finishClose(attempt) + } + } else { + attempt.pickerTerminal = true + finishClose(attempt) + } + } else { finishClose(attempt) } + } + + private func finishClose(_ attempt: Attempt) { + attempt.condition.lock() + let terminal = attempt.terminal + let failed = attempt.cleanupFailed + let cancelled = attempt.cancelled + attempt.condition.unlock() + guard cancelled && terminal && attempt.pickerTerminal else { return } + let reply = attempt.closeResult + attempt.closeResult = nil + // A close failure does not claim the reader was cleaned up. The owner stays + // retained for explicit cleanup reporting; no subsequent import is admitted. + guard !failed else { reply?(Self.failure()); return } + if active === attempt { active = nil } + lastClosed = attempt.id + reply?(true) + } + + private enum ImportError: Error { case io, limit, cancelled } + private static func failure(_ code: String = "import_io") -> FlutterError { + FlutterError(code: code, message: "Backup input could not be read.", details: nil) + } + private static func deliver(_ result: @escaping FlutterResult, _ value: Any?) { + DispatchQueue.main.async { result(value) } + } +} diff --git a/lib/core/backup/backup_authenticated_time_review.dart b/lib/core/backup/backup_authenticated_time_review.dart new file mode 100644 index 00000000..3a71e994 --- /dev/null +++ b/lib/core/backup/backup_authenticated_time_review.dart @@ -0,0 +1,516 @@ +part of 'backup_validated_ingestion.dart'; + +/// Owns authenticated portable literals only. The callback can build a ready +/// staging database only after a fresh validator has checked the entire graph. +class BackupAuthenticatedTimeReview extends BackupTimeReviewInput + implements BackupRecurringTimeReviewPort { + BackupAuthenticatedTimeReview._( + this._store, + this._root, + this._now, + this._ready, + ); + final BackupIngestionStore _store; + final int _root; + final DateTime Function() _now; + final Future Function(BackupValidatedIngestion) _ready; + final String _identity = const Uuid().v4(); + int _revision = 0; + late BackupValidatedIngestion _data; + late String _rules; + late DateTime _validationNow; + Future? _flight; + bool _disposed = false; + bool _hasValidation = false; + bool _structureChecked = false; + final _confirmedBoundaries = {}; + bool _transferred = false; + Future? _disposal; + final _issued = {}; + final _recurringIssued = {}; + @override + Future recurrenceRule(String issueId) => + _readRecurrenceRule(issueId); + @override + Future reviewRecurrence( + BackupRecurringTimeDraft draft, + ) => _reviewRecurrence(draft); + @override + Future chooseRecurrence(BackupRecurringTimeChoice choice) => + _chooseRecurrence(choice); + BackupProcessingLease get _lease => _store.budget.lease!; + + static Future decrypt({ + required Stream> ciphertext, + required String password, + required BackupCrypto crypto, + required BackupBudget budget, + required Future Function(BackupBudget) createStore, + required DateTime Function() now, + required Future Function(BackupValidatedIngestion) + ready, + }) async { + final store = await createStore(budget); + BackupAuthenticatedTimeReview? owner; + try { + final root = await BackupJsonReader(store, budget, portableRecords: true) + .read( + crypto.decryptStream( + container: ciphertext, + password: password, + budget: budget, + ), + ); + store.finish(); + owner = BackupAuthenticatedTimeReview._(store, root, now, ready); + return await owner.revalidate(); + } catch (original) { + try { + if (owner == null) { + await store.release(); + } else { + await owner.dispose(); + } + } catch (cleanup) { + budget.lease?.retainCleanup(store.release); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + void _check({bool idle = true}) { + if (_disposed || _transferred || (idle && _flight != null)) { + throw const BackupTimeReviewStale(); + } + _lease.check(); + } + + @override + BackupTimeReviewSummary get summary => BackupTimeReviewSummary( + identity: _identity, + revision: _revision, + validationNowUtc: _validationNow, + rulesIdentity: _rules, + issueCount: _data.timeIssueCount, + independentStructureChecked: _structureChecked, + ); + + BackupTimeReviewIssue _issue(Map row) { + final parent = int.parse(row['owner'] as String); + final field = row['reference'] as String; + final literal = _store.scalar(row['node'] as int) as String; + final path = _store.fieldPath(parent, field); + final fields = _store.fields(parent, { + 'name', + 'timeZoneId', + 'occurrenceOffsetSeconds', + }); + BackupTimeFieldKind kind; + CivilDateTime civil; + String? zone = fields['timeZoneId'] as String?; + if (field == 'ruleJson') { + final segment = _data._segment(parent); + final spec = _data._specCache[segment.id]; + final rule = jsonDecode(literal) as Map; + civil = CivilDateTime.parse(rule['start'] as String); + zone = rule['zone'] as String; + kind = BackupTimeFieldKind.recurrenceRule; + return BackupTimeReviewIssue( + id: row['identity'] as String, + kind: kind, + reason: BackupTimeIssueReason.values.byName(row['detail'] as String), + fieldPath: path, + name: + spec?.schedule.scheduleName ?? + (jsonDecode(segment.scheduleJson) as Map)['name'] + as String, + originalLiteral: _store.originalTimeScalar(parent, field) as String, + currentLiteral: literal, + civil: civil, + zone: zone, + ); + } + civil = CivilDateTime.parse(literal); + kind = switch (field) { + 'civilTime' => BackupTimeFieldKind.scheduleOccurrence, + 'startedAt' => BackupTimeFieldKind.scheduleStartedAt, + 'finishedAt' => BackupTimeFieldKind.scheduleFinishedAt, + 'createdAt' => BackupTimeFieldKind.templateCreatedAt, + 'updatedAt' => BackupTimeFieldKind.templateUpdatedAt, + _ => throw const BackupTimeReviewStale(), + }; + return BackupTimeReviewIssue( + id: row['identity'] as String, + kind: kind, + reason: BackupTimeIssueReason.values.byName(row['detail'] as String), + fieldPath: path, + name: fields['name'] as String? ?? '', + originalLiteral: _store.originalTimeScalar(parent, field) as String, + currentLiteral: literal, + civil: civil, + zone: zone, + selectedOffsetSeconds: fields['occurrenceOffsetSeconds'] as int?, + ); + } + + @override + Future issues({String? cursor}) async { + _check(); + final after = cursor == null ? 0 : int.tryParse(cursor); + if (after == null || after < 0) throw ArgumentError('Invalid issue cursor'); + if (!_structureChecked) throw const BackupTimeReviewStale(); + final rows = _store.timeIssuePage(after); + return BackupTimeReviewIssuePage( + rows.take(20).map(_issue).toList(), + nextCursor: rows.length > 20 ? '${rows[19]['node']}' : null, + ); + } + + void _checkDraft(BackupTimeDraft draft) { + _check(); + if (!_structureChecked || + draft.identity != _identity || + draft.revision != _revision || + draft.rulesIdentity != _rules || + TimeZoneRules.loadedIdentity != _rules || + _now().toUtc().isBefore(_validationNow)) { + throw const BackupTimeReviewStale(); + } + } + + Future _conflictProof( + int parent, + BackupTimeDraft draft, + int offset, + DateTime now, + ) async { + final stored = _data._schedule(parent); + final desired = stored.copyWith( + scheduleTime: draft.civil.toUtcCarrier(), + timeZoneId: draft.zone, + occurrenceOffsetSeconds: offset, + ); + final instant = draft.civil.atOffset(offset); + final lead = _data._leadFor(desired); + if (!instant.subtract(lead).isAfter(now)) { + throw const BackupTimeReviewStale(); + } + final world = await _BackupConflictWorld.read(_data, now, {stored.id}); + final occurrences = CivilTimeResolver.resolve( + draft.civil.toUtcCarrier(), + draft.zone, + ); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: draft.civil.toUtcCarrier(), + timeZoneId: draft.zone, + count: 1, + repeatedTime: occurrences.length > 1 + ? occurrences.first.offsetSeconds == offset + ? RepeatedCivilTime.first + : RepeatedCivilTime.second + : null, + ); + final preparation = _data._effectivePreparation(stored).totalDuration; + final proof = await TimeCorrectionConflictScanner.scan( + rule: rule, + base: desired, + basePreparation: preparation, + proposedOverrides: {rule.start.toIso8601String(): desired}, + proposedPreparationById: {desired.id: preparation}, + excludedSlots: {}, + storedOthers: world.rows, + otherSegments: world.segments, + otherExcludedSlots: world.exclusions, + nowUtc: now, + budget: _store.budget, + ); + return TimeCorrectionConflictProof( + conflicts: proof.conflicts, + through: proof.through, + workUnits: _store.budget.work, + earliestPreparationUtc: instant.subtract(lead), + latestTargetUtc: instant, + possibleOverlaps: [...world.possible, ...proof.possibleOverlaps] + .where((item) => item.mayOverlap(instant.subtract(lead), instant)) + .toList(), + ); + } + + @override + Future review(BackupTimeDraft draft) async { + _checkDraft(draft); + final row = _store.record('timeIssue', draft.issueId); + if (row == null) throw const BackupTimeReviewStale(); + final issue = _issue(row); + if (issue.kind == BackupTimeFieldKind.recurrenceRule) { + // Scalar editing cannot authorize graph/ordinal remapping. + throw const BackupProcessingFailure( + BackupFailureKind.timeZoneChoiceRequired, + ); + } + BackupValueValidation.namedZone(draft.zone); + var choices = CivilTimeResolver.resolve( + draft.civil.toUtcCarrier(), + draft.zone, + ); + final next = choices.isEmpty + ? CivilTimeResolver.nextValidCivilTime( + draft.civil.toUtcCarrier(), + draft.zone, + ) + : null; + final conflicts = {}; + final reviewedAt = _now().toUtc(); + if (issue.kind == BackupTimeFieldKind.scheduleOccurrence) { + final lead = _data._leadFor( + _data._schedule(int.parse(row['owner'] as String)), + ); + choices = choices + .where( + (candidate) => + candidate.instantUtc.subtract(lead).isAfter(reviewedAt), + ) + .toList(); + for (final candidate in choices) { + conflicts[candidate.offsetSeconds] = await _conflictProof( + int.parse(row['owner'] as String), + draft, + candidate.offsetSeconds, + reviewedAt, + ); + } + } + _checkDraft(draft); + if (_now().toUtc().isBefore(reviewedAt)) { + throw const BackupTimeReviewStale(); + } + final value = BackupTimeFieldReview( + draft: draft, + issue: issue, + choices: choices, + conflictsByOffset: conflicts, + nextValidCivil: next == null ? null : CivilDateTime.fromFields(next), + previousInstantUtc: issue.selectedOffsetSeconds == null + ? null + : issue.civil.atOffset(issue.selectedOffsetSeconds!), + ); + // Bound outstanding UI authority even when a caller repeatedly previews. + if (_issued.length == 20) _issued.remove(_issued.keys.first); + _issued[value] = _now().toUtc(); + return value; + } + + @override + Future choose(BackupTimeChoice choice) async { + final fieldReview = choice.review; + final draft = fieldReview.draft; + _checkDraft(draft); + final issuedAt = _issued[fieldReview]; + final now = _now().toUtc(); + if (issuedAt == null || now.isBefore(issuedAt)) { + throw const BackupTimeReviewStale(); + } + final row = _store.record('timeIssue', draft.issueId); + if (row == null) throw const BackupTimeReviewStale(); + final issue = _issue(row); + final candidates = CivilTimeResolver.resolve( + draft.civil.toUtcCarrier(), + draft.zone, + ); + final selected = candidates + .where((value) => value.offsetSeconds == choice.offsetSeconds) + .firstOrNull; + if (selected == null) throw const BackupTimeReviewStale(); + final parent = int.parse(row['owner'] as String); + final changes = {}; + DateTime? confirmedBoundary; + if (issue.kind == BackupTimeFieldKind.scheduleOccurrence) { + final schedule = _data._schedule(parent); + final oldInstant = schedule.occurrenceOffsetSeconds == null + ? null + : CivilDateTime.fromFields( + schedule.scheduleTime, + ).atOffset(schedule.occurrenceOffsetSeconds!); + if (schedule.startedAt != null || + schedule.preparationFrozen || + schedule.doneStatus != ScheduleDoneStatus.notEnded || + (oldInstant != null && !oldInstant.isAfter(now)) || + (oldInstant == null && + !schedule.scheduleTime + .add(const Duration(hours: 24)) + .isAfter(now))) { + throw const BackupTimeReviewStale(); + } + final proof = await _conflictProof( + parent, + draft, + choice.offsetSeconds, + now, + ); + _checkDraft(draft); + final finalNow = _now().toUtc(); + final lead = _data._leadFor(schedule); + if (finalNow.isBefore(now) || + !selected.instantUtc.subtract(lead).isAfter(finalNow)) { + throw const BackupTimeReviewStale(); + } + if (proof.conflicts.isNotEmpty || + !const SetEquality().equals( + proof.possibleOverlaps.map((item) => item.id).toSet(), + choice.acknowledgedPossibleIds, + )) { + throw BackupTimeChoiceConflict(proof); + } + confirmedBoundary = selected.instantUtc.subtract(lead); + changes.addAll({ + 'civilTime': draft.civil.toCivilIso8601String(), + 'timeZoneId': draft.zone, + 'occurrenceOffsetSeconds': choice.offsetSeconds, + }); + if (schedule.isRecurring) { + final mask = { + ...schedule.recurringOverrides + .split(',') + .where((value) => value.isNotEmpty), + 'time', + }.toList()..sort(); + changes['recurringOverrides'] = mask.join(','); + } + } else { + if (issue.reason != BackupTimeIssueReason.missingInstantOffset || + draft.civil != issue.civil) { + throw const BackupTimeReviewStale(); + } + changes[row['reference'] as String] = selected.instantUtc + .toIso8601String(); + } + _store.editTimeScalars([ + for (final entry in changes.entries) + BackupTimeScalarEdit( + parent: parent, + field: entry.key, + expectedExists: _store.child(parent, entry.key) != null, + expectedValue: _store.child(parent, entry.key) == null + ? null + : _store.scalar(_store.child(parent, entry.key)!), + value: entry.value, + ), + ]); + if (confirmedBoundary != null) { + _confirmedBoundaries[draft.issueId] = confirmedBoundary; + } + _revision++; + _issued.clear(); + _recurringIssued.clear(); + } + + @override + Future revalidate() async { + _check(); + _lease.beginOperation(); + return _flight = _validateAndTransfer().whenComplete(() { + _flight = null; + _lease.endOperation(); + }); + } + + Future _validateAndTransfer() async { + final nextNow = _now().toUtc(); + if ((_hasValidation && nextNow.isBefore(_validationNow)) || + _confirmedBoundaries.values.any((value) => !nextNow.isBefore(value))) { + throw const BackupTimeReviewStale(); + } + _structureChecked = false; + _store.resetDerivedRecords(); + _validationNow = nextNow; + _hasValidation = true; + _rules = TimeZoneRules.loadedIdentity; + _revision++; + _issued.clear(); + _recurringIssued.clear(); + _data = BackupValidatedIngestion._( + _store, + _root, + _validationNow, + backupOffsets, + true, + ); + await _store.validateRecords(_data._validate); + _check(idle: false); + _structureChecked = true; + if (_now().toUtc().isBefore(_validationNow) || + TimeZoneRules.loadedIdentity != _rules) { + throw const BackupTimeReviewStale(); + } + if (_data.timeIssueCount != 0) return this; + _data._validated = true; + _data.establishTimeAuthority(_rules); + final ready = await _ready(_data); + try { + _check(idle: false); + if (!_data.hasCurrentTimeAuthority(_now())) { + throw const BackupTimeReviewStale(); + } + } catch (original) { + // The ready owner holds the newly allocated staging resources. Every + // failed final check, including external cancellation, must retire it. + _transferred = true; + try { + await ready.dispose(); + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + _transferred = true; + return ready; + } + + @override + Future dispose() { + if (_transferred) return Future.value(); + return _disposal ??= _dispose().catchError(( + Object error, + StackTrace stack, + ) { + // Keep the same cleanup owner, but do not cache a failed attempt forever. + _disposal = null; + Error.throwWithStackTrace(error, stack); + }); + } + + Future _dispose() async { + _disposed = true; + _issued.clear(); + _recurringIssued.clear(); + _lease.requestCancellation(); + final flight = _flight; + if (flight != null) { + try { + await flight; + } catch (_) { + /* Cleanup remains owned below. */ + } + } + if (_transferred) return; + if (_lease.phase == BackupProcessingPhase.cleanupPending) { + await _lease.retryCleanup(); + return; + } + try { + await _store.release(); + } catch (_) { + _lease.retainCleanup(_store.release); + rethrow; + } + _lease.release(); + } +} diff --git a/lib/core/backup/backup_content.dart b/lib/core/backup/backup_content.dart new file mode 100644 index 00000000..771db358 --- /dev/null +++ b/lib/core/backup/backup_content.dart @@ -0,0 +1,749 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'backup_limits.dart'; +import 'backup_value_validation.dart'; +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/recurring_backup_data.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +/// The single authenticated portable-content codec and materializer. No active +/// database, revision, installation key, GetIt or startup state is required. +class BackupContent { + static Future decrypt( + Uint8List encrypted, + String password, + BackupCrypto crypto, + ) async { + final plaintext = await crypto.decrypt( + container: encrypted, + password: password, + ); + return BackupContent.fromJson( + _asMap(jsonDecode(utf8.decode(plaintext)), 'backup'), + ); + } + + // Shared bounded-record decoding used by the streaming ingestion pipeline. + // Callers must not collect these records into a whole-backup object. + static ScheduleEntity decodeSchedule(Map json) { + final value = _scheduleFromJson(json); + _validateSchedule(value); + return value; + } + + /// Scalar/shape admission only for the contextual streaming validator. + /// This does not grant preview, materialization, or execution authority. + /// In particular, an unknown zone still requires historical classification + /// or explicit time review under a fixed validation instant. + static ScheduleEntity decodeScheduleStructure(Map json) { + final value = _scheduleFromJson(json); + _validateScheduleStructure(value); + return value; + } + + static PreparationEntity decodePreparation(List json) => + _preparationFromJson(json); + static Map encodeSchedule(ScheduleEntity value) => + _scheduleToJson(value); + static List> encodePreparation( + PreparationEntity value, + ) => _preparationToJson(value); + + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: cutoff, + cutoffLiteral: cutoffLiteral, + sourceAppVersion: sourceAppVersion, + sourcePlatform: sourcePlatform, + scheduleCount: schedules.length, + templateCount: templates.length, + defaultPreparationStepCount: defaultPreparation.preparationStepList.length, + ); + + Future validateReadBack(AppDatabase database) async { + final foreignKeys = await database + .customSelect('PRAGMA foreign_key_check') + .get(); + final integrity = await database.customSelect('PRAGMA quick_check').get(); + if (foreignKeys.isNotEmpty || + integrity.length != 1 || + integrity.single.data.values.single != 'ok') { + throw const FormatException('Staging integrity validation failed'); + } + final observed = await capture( + database, + cutoff: cutoff, + sourceAppVersion: sourceAppVersion, + sourcePlatform: sourcePlatform, + ); + if (!const DeepCollectionEquality().equals( + _canonicalDurable(this), + _canonicalDurable(observed), + )) { + throw const FormatException('Staging read-back differs from backup'); + } + } + + Future writeTo( + AppDatabase database, { + required bool pendingCleanup, + }) async { + final data = this; + final profile = data.profile.valueOrNull!; + await database.transaction(() async { + await database.deleteAllDurableData(); + await database + .into(database.users) + .insert( + UsersCompanion.insert( + id: const Value(localProfileId), + restoreCleanupPending: Value(pendingCleanup), + rejectLegacyDelivery: Value(pendingCleanup), + spareTime: profile.spareTime.inMinutes, + note: profile.note, + isOnboardingCompleted: Value(profile.isOnboardingCompleted), + eligibleOutcomeCount: Value(profile.eligibleOutcomeCount), + onTimeOutcomeCount: Value(profile.onTimeOutcomeCount), + alarmsEnabled: Value(data.alarmsEnabled), + alarmOffsetMinutes: Value(data.alarmOffsetMinutes), + detailedNotificationContent: Value( + data.detailedNotificationContent, + ), + dataRevision: Value(data.dataRevision + 1), + firstDurableDataAt: Value( + data.cutoffLiteral == null + ? data.cutoff + : DateTime.now().toUtc(), + ), + lastDurableDataAt: Value( + data.cutoffLiteral == null + ? data.cutoff + : DateTime.now().toUtc(), + ), + ), + ); + await database.preparationUserDao.createPreparationUser( + data.defaultPreparation, + localProfileId, + ); + await data.recurring.restore(database); + for (final schedule in data.schedules) { + // The app's only freeze writer records the first start in the same TX. + // Historical backups omit both fields and keep null/false defaults. + if (schedule.preparationFrozen && schedule.startedAt == null) { + throw const FormatException( + 'Frozen preparation requires a first start', + ); + } + await database.scheduleDao.createSchedule( + schedule.toScheduleWithPlaceRow(), + ); + if (pendingCleanup && + schedule.doneStatus == ScheduleDoneStatus.notEnded) { + await (database.update( + database.schedules, + )..where((t) => t.id.equals(schedule.id))).write( + const SchedulesCompanion(requiresStartConfirmation: Value(true)), + ); + } + final preparation = data.schedulePreparations[schedule.id]; + if (preparation != null && preparation.preparationStepList.isNotEmpty) { + await database.preparationScheduleDao.createPreparationSchedule( + preparation, + schedule.id, + ); + } + } + for (final template in data.templates) { + await database.preparationTemplateDao.restore( + id: template.id, + name: template.name, + preparation: template.preparation, + createdAt: template.createdAt, + updatedAt: template.updatedAt, + ); + } + }); + } + + static Future capture( + AppDatabase target, { + required DateTime cutoff, + required String sourceAppVersion, + required String sourcePlatform, + }) async { + return target.transaction(() async { + final user = await (target.select( + target.users, + )..where((table) => table.id.equals(localProfileId))).getSingle(); + final scheduleRows = await target.scheduleDao.getScheduleList(); + final schedulePreparations = {}; + for (final row in scheduleRows) { + schedulePreparations[row.schedule.id] = await target + .preparationScheduleDao + .getPreparationSchedulesByScheduleId(row.schedule.id); + } + return BackupContent( + cutoff: cutoff, + sourceAppVersion: sourceAppVersion, + sourcePlatform: sourcePlatform, + dataRevision: user.dataRevision, + profile: user.toUserEntity(), + alarmsEnabled: user.alarmsEnabled, + alarmOffsetMinutes: user.alarmOffsetMinutes, + detailedNotificationContent: user.detailedNotificationContent, + schedules: scheduleRows.map((row) => row.toScheduleEntity()).toList(), + defaultPreparation: await target.preparationUserDao + .getPreparationUsersByUserId(localProfileId), + schedulePreparations: schedulePreparations, + templates: await target.preparationTemplateDao.getAll(), + recurring: await RecurringBackupData.capture(target), + ); + }); + } + + const BackupContent({ + required this.cutoff, + this.cutoffLiteral, + required this.sourceAppVersion, + required this.sourcePlatform, + required this.dataRevision, + required this.profile, + required this.alarmsEnabled, + required this.alarmOffsetMinutes, + required this.detailedNotificationContent, + required this.schedules, + required this.defaultPreparation, + required this.schedulePreparations, + required this.templates, + this.recurring = const RecurringBackupData(), + }); + + final DateTime cutoff; + + /// Present only for legacy metadata lacking an instant offset. + final String? cutoffLiteral; + final String sourceAppVersion; + final String sourcePlatform; + final int dataRevision; + final UserEntity profile; + final bool alarmsEnabled; + final int alarmOffsetMinutes; + final bool detailedNotificationContent; + final List schedules; + final PreparationEntity defaultPreparation; + final Map schedulePreparations; + final List templates; + final RecurringBackupData recurring; + + Map toJson() => { + 'formatVersion': 2, + 'recurring': recurring.toJson(), + 'cutoff': cutoffLiteral ?? cutoff.toUtc().toIso8601String(), + 'sourceAppVersion': sourceAppVersion, + 'sourcePlatform': sourcePlatform, + 'dataRevision': dataRevision, + 'profile': { + 'spareTimeMinutes': profile.valueOrNull!.spareTime.inMinutes, + 'note': profile.valueOrNull!.note, + 'isOnboardingCompleted': profile.valueOrNull!.isOnboardingCompleted, + 'eligibleOutcomeCount': profile.valueOrNull!.eligibleOutcomeCount, + 'onTimeOutcomeCount': profile.valueOrNull!.onTimeOutcomeCount, + }, + 'preferences': { + 'alarmsEnabled': alarmsEnabled, + 'alarmOffsetMinutes': alarmOffsetMinutes, + 'detailedNotificationContent': detailedNotificationContent, + }, + 'schedules': schedules.map(_scheduleToJson).toList(), + 'defaultPreparation': _preparationToJson(defaultPreparation), + 'schedulePreparations': { + for (final entry in schedulePreparations.entries) + entry.key: _preparationToJson(entry.value), + }, + 'templates': templates + .map( + (template) => { + 'id': template.id, + 'name': template.name, + 'createdAt': template.createdAt.toUtc().toIso8601String(), + 'updatedAt': template.updatedAt.toUtc().toIso8601String(), + 'preparation': _preparationToJson(template.preparation), + }, + ) + .toList(), + }; + + factory BackupContent.fromJson(Map json) { + final version = _asInt(json['formatVersion'], 'formatVersion'); + if (version != 1 && version != 2) { + throw const FormatException('Unsupported backup data version.'); + } + final profile = _asMap(json['profile'], 'profile'); + final preferences = _asMap(json['preferences'], 'preferences'); + final schedulePreparations = _asMap( + json['schedulePreparations'], + 'schedulePreparations', + ); + final templates = _asList(json['templates'], 'templates'); + final result = BackupContent( + recurring: version == 1 + ? const RecurringBackupData() + : RecurringBackupData.fromJson(json['recurring']), + cutoff: BackupValueValidation.date( + _asString(json['cutoff'], 'cutoff'), + civilTime: !BackupValueValidation.explicitOffset( + _asString(json['cutoff'], 'cutoff'), + ), + ), + cutoffLiteral: + BackupValueValidation.explicitOffset( + _asString(json['cutoff'], 'cutoff'), + ) + ? null + : _asString(json['cutoff'], 'cutoff'), + sourceAppVersion: _asString(json['sourceAppVersion'], 'sourceAppVersion'), + sourcePlatform: _asString(json['sourcePlatform'], 'sourcePlatform'), + dataRevision: BackupLimits.integer( + json['dataRevision'], + maximum: BackupLimits.exactInteger - 1, + ), + profile: UserEntity( + id: localProfileId, + spareTime: Duration( + minutes: _asNonNegativeInt( + profile['spareTimeMinutes'], + 'profile.spareTimeMinutes', + ), + ), + note: _asString(profile['note'], 'profile.note'), + isOnboardingCompleted: _asBool( + profile['isOnboardingCompleted'], + 'profile.isOnboardingCompleted', + ), + eligibleOutcomeCount: _asNonNegativeInt( + profile['eligibleOutcomeCount'], + 'profile.eligibleOutcomeCount', + ), + onTimeOutcomeCount: _asNonNegativeInt( + profile['onTimeOutcomeCount'], + 'profile.onTimeOutcomeCount', + ), + ), + alarmsEnabled: _asBool( + preferences['alarmsEnabled'], + 'preferences.alarmsEnabled', + ), + alarmOffsetMinutes: _asNonNegativeInt( + preferences['alarmOffsetMinutes'], + 'preferences.alarmOffsetMinutes', + ), + detailedNotificationContent: _asBool( + preferences['detailedNotificationContent'], + 'preferences.detailedNotificationContent', + ), + schedules: _asList( + json['schedules'], + 'schedules', + ).map((value) => _scheduleFromJson(_asMap(value, 'schedule'))).toList(), + defaultPreparation: _preparationFromJson( + _asList(json['defaultPreparation'], 'defaultPreparation'), + ), + schedulePreparations: { + for (final entry in schedulePreparations.entries) + entry.key: _preparationFromJson( + _asList(entry.value, 'schedulePreparations.${entry.key}'), + ), + }, + templates: templates.map((value) { + final map = _asMap(value, 'template'); + return PreparationTemplateEntity( + id: _asString(map['id'], 'template.id'), + name: _asString(map['name'], 'template.name'), + createdAt: _templateInstant(map['createdAt'], 'template.createdAt'), + updatedAt: _templateInstant(map['updatedAt'], 'template.updatedAt'), + preparation: _preparationFromJson( + _asList(map['preparation'], 'template.preparation'), + ), + ); + }).toList(), + ); + _validateBackupData(result); + return result; + } +} + +void _validateBackupData(BackupContent data) { + data.recurring.validate(data.schedules); + final profile = data.profile.valueOrNull!; + if (profile.onTimeOutcomeCount > profile.eligibleOutcomeCount) { + throw const FormatException( + 'On-time outcome count cannot exceed eligible outcome count.', + ); + } + if (data.alarmOffsetMinutes > 24 * 60) { + throw const FormatException('Alarm offset is outside the supported range.'); + } + final scheduleIds = {}; + for (final schedule in data.schedules) { + if (schedule.id.isEmpty || !scheduleIds.add(schedule.id)) { + throw const FormatException('Schedule identifiers must be unique.'); + } + _validateSchedule(schedule); + } + if (!data.schedulePreparations.keys.every(scheduleIds.contains)) { + throw const FormatException( + 'Schedule preparation references an unknown schedule.', + ); + } + final templateIds = {}; + for (final template in data.templates) { + if (template.id.isEmpty || !templateIds.add(template.id)) { + throw const FormatException('Template identifiers must be unique.'); + } + } + _validatePreparation(data.defaultPreparation); + for (final preparation in data.schedulePreparations.values) { + _validatePreparation(preparation); + } + for (final template in data.templates) { + _validatePreparation(template.preparation); + } +} + +void _validateSchedule(ScheduleEntity schedule) { + _validateScheduleStructure(schedule); + BackupValueValidation.namedZone(schedule.timeZoneId); +} + +void _validateScheduleStructure(ScheduleEntity schedule) { + BackupLimits.string(schedule.id, identifier: true); + if (schedule.id.isEmpty || + schedule.place.id.isEmpty || + schedule.place.placeName.isEmpty || + schedule.place.placeName.length > 30) { + BackupLimits.invalid(); + } + BackupLimits.string(schedule.place.id, identifier: true); + BackupValueValidation.zoneIdentifier(schedule.timeZoneId); + if (schedule.moveTime.isNegative || + (schedule.scheduleSpareTime?.isNegative ?? false)) { + BackupLimits.invalid(); + } + final offset = schedule.occurrenceOffsetSeconds; + if (offset != null && (offset < -24 * 60 * 60 || offset > 24 * 60 * 60)) { + BackupLimits.invalid(); + } + if (schedule.preparationFrozen && schedule.startedAt == null) { + BackupLimits.invalid(); + } +} + +void _validatePreparation(PreparationEntity preparation) { + if (preparation.preparationStepList.length > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + final ids = {}; + for (final step in preparation.preparationStepList) { + if (step.preparationName.isEmpty || step.preparationName.length > 30) { + BackupLimits.invalid(); + } + if (step.id.isEmpty || !ids.add(step.id)) { + throw const FormatException( + 'Preparation step identifiers must be unique.', + ); + } + } + final incoming = {}; + for (final step in preparation.preparationStepList) { + final nextId = step.nextPreparationId; + if (nextId != null) incoming[nextId] = (incoming[nextId] ?? 0) + 1; + if (nextId != null && !ids.contains(nextId)) { + throw const FormatException( + 'Preparation step references an unknown next step.', + ); + } + } + if (ids.isEmpty) return; + final heads = ids.where((id) => !incoming.containsKey(id)).toList(); + if (heads.length != 1 || incoming.values.any((count) => count != 1)) { + BackupLimits.invalid(); + } + final byId = { + for (final step in preparation.preparationStepList) step.id: step, + }; + final visited = {}; + String? current = heads.single; + while (current != null) { + if (!visited.add(current)) BackupLimits.invalid(); + current = byId[current]!.nextPreparationId; + } + if (visited.length != ids.length) BackupLimits.invalid(); +} + +Map _scheduleToJson(ScheduleEntity value) => { + 'id': value.id, + 'place': {'id': value.place.id, 'name': value.place.placeName}, + 'name': value.scheduleName, + 'civilTime': CivilDateTime.fromFields( + value.scheduleTime, + ).toCivilIso8601String(), + 'timeZoneId': value.timeZoneId, + 'occurrenceOffsetSeconds': value.occurrenceOffsetSeconds, + 'moveTimeMinutes': value.moveTime.inMinutes, + 'isChanged': value.isChanged, + 'spareTimeMinutes': value.scheduleSpareTime?.inMinutes, + 'note': value.scheduleNote, + 'latenessTime': value.latenessTime, + 'doneStatus': value.doneStatus.name, + 'finishedAt': value.finishedAt?.toUtc().toIso8601String(), + 'startedAt': value.startedAt?.toUtc().toIso8601String(), + 'preparationFrozen': value.preparationFrozen, + 'preparationMode': value.preparationMode?.name, + 'preparationTemplateId': value.preparationTemplateId, + 'preparationTemplateName': value.preparationTemplateName, + 'preparationTemplateDeleted': value.preparationTemplateDeleted, + 'scoreContributionRecorded': value.scoreContributionRecorded, + 'recurringSegmentId': value.recurringSegmentId, + 'recurringSlotKey': value.recurringSlotKey, + 'recurringOrdinal': value.recurringOrdinal, + 'recurringOverrides': value.recurringOverrides, + 'preparationDefinitionId': value.preparationDefinitionId, +}; + +ScheduleEntity _scheduleFromJson(Map json) { + final place = _asMap(json['place'], 'schedule.place'); + final eligibleCount = _nullableInt( + json['occurrenceOffsetSeconds'], + 'schedule.occurrenceOffsetSeconds', + ); + return ScheduleEntity( + recurringSegmentId: _nullableString( + json['recurringSegmentId'], + 'schedule.recurringSegmentId', + ), + recurringSlotKey: _nullableString( + json['recurringSlotKey'], + 'schedule.recurringSlotKey', + ), + recurringOrdinal: _nullableInt( + json['recurringOrdinal'], + 'schedule.recurringOrdinal', + ), + recurringOverrides: + _nullableString( + json['recurringOverrides'], + 'schedule.recurringOverrides', + ) ?? + '', + preparationDefinitionId: _nullableString( + json['preparationDefinitionId'], + 'schedule.preparationDefinitionId', + ), + id: _asString(json['id'], 'schedule.id'), + place: PlaceEntity( + id: _asString(place['id'], 'schedule.place.id'), + placeName: _asString(place['name'], 'schedule.place.name'), + ), + scheduleName: _asString(json['name'], 'schedule.name'), + scheduleTime: _asDate(json['civilTime'], 'schedule.civilTime'), + timeZoneId: _asString(json['timeZoneId'], 'schedule.timeZoneId'), + occurrenceOffsetSeconds: eligibleCount, + moveTime: Duration( + minutes: _asNonNegativeInt( + json['moveTimeMinutes'], + 'schedule.moveTimeMinutes', + ), + ), + isChanged: _asBool(json['isChanged'], 'schedule.isChanged'), + isStarted: false, + scheduleSpareTime: json['spareTimeMinutes'] == null + ? null + : Duration(minutes: BackupLimits.minutes(json['spareTimeMinutes'])), + scheduleNote: _asString(json['note'], 'schedule.note'), + latenessTime: _asInt(json['latenessTime'], 'schedule.latenessTime'), + doneStatus: ScheduleDoneStatus.values.byName( + _asString(json['doneStatus'], 'schedule.doneStatus'), + ), + startedAt: _nullableDate(json['startedAt'], 'schedule.startedAt'), + finishedAt: _nullableDate(json['finishedAt'], 'schedule.finishedAt'), + preparationMode: json['preparationMode'] == null + ? null + : SchedulePreparationMode.values.byName( + _asString(json['preparationMode'], 'schedule.preparationMode'), + ), + preparationTemplateId: _nullableString( + json['preparationTemplateId'], + 'schedule.preparationTemplateId', + ), + preparationTemplateName: _nullableString( + json['preparationTemplateName'], + 'schedule.preparationTemplateName', + ), + preparationTemplateDeleted: _asBool( + json['preparationTemplateDeleted'], + 'schedule.preparationTemplateDeleted', + ), + preparationFrozen: json['preparationFrozen'] == null + ? false + : _asBool(json['preparationFrozen'], 'schedule.preparationFrozen'), + scoreContributionRecorded: _asBool( + json['scoreContributionRecorded'], + 'schedule.scoreContributionRecorded', + ), + ); +} + +List> _preparationToJson(PreparationEntity value) => [ + for (final step in value.ordered.preparationStepList) + { + 'id': step.id, + 'name': step.preparationName, + 'minutes': step.preparationTime.inMinutes, + 'nextId': step.nextPreparationId, + }, +]; + +PreparationEntity _preparationFromJson(List values) { + if (values.length > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + final preparation = PreparationEntity( + preparationStepList: values.map((value) { + final map = _asMap(value, 'preparationStep'); + return PreparationStepEntity( + id: _asString(map['id'], 'preparationStep.id'), + preparationName: _asString(map['name'], 'preparationStep.name'), + preparationTime: Duration( + minutes: _asNonNegativeInt(map['minutes'], 'preparationStep.minutes'), + ), + nextPreparationId: _nullableString( + map['nextId'], + 'preparationStep.nextId', + ), + ); + }).toList(), + ); + _validatePreparation(preparation); + return preparation.ordered; +} + +Map _asMap(Object? value, String field) { + if (value is Map) return value; + throw FormatException('$field must be an object.'); +} + +List _asList(Object? value, String field) { + if (value is List) return value; + throw FormatException('$field must be a list.'); +} + +String _asString(Object? value, String field) { + if (value is String) { + BackupLimits.string( + value, + identifier: field.endsWith('.id') || field.endsWith('Id'), + note: field.endsWith('.note'), + ); + return value; + } + throw FormatException('$field must be a string.'); +} + +String? _nullableString(Object? value, String field) => + value == null ? null : _asString(value, field); + +int _asInt(Object? value, String field) { + if (value is int) { + return BackupLimits.integer(value, minimum: -BackupLimits.exactInteger); + } + throw FormatException('$field must be an integer.'); +} + +int _asNonNegativeInt(Object? value, String field) { + final result = _asInt(value, field); + if (result < 0) throw FormatException('$field must not be negative.'); + if (field.endsWith('Minutes') || field.endsWith('.minutes')) { + BackupLimits.minutes(result); + } + return result; +} + +int? _nullableInt(Object? value, String field) => + value == null ? null : _asInt(value, field); + +bool _asBool(Object? value, String field) { + if (value is bool) return value; + throw FormatException('$field must be a boolean.'); +} + +DateTime _asDate(Object? value, String field) { + return BackupValueValidation.date( + _asString(value, field), + civilTime: field == 'schedule.civilTime', + ); +} + +DateTime? _nullableDate(Object? value, String field) => value == null + ? null + : (field == 'schedule.startedAt' || field == 'schedule.finishedAt') + ? BackupValueValidation.instant(_asString(value, field)) + : _asDate(value, field); + +Object _canonicalDurable(BackupContent data) { + final value = data.toJson() + ..remove('cutoff') + ..remove('sourceAppVersion') + ..remove('sourcePlatform') + ..remove('dataRevision'); + Object? canonical(Object? value, [String? field]) { + if (value is Map) { + return { + for (final key in value.keys) key: canonical(value[key], key as String), + }; + } + if (value is List) { + final items = value.map((e) => canonical(e)).toList(); + if (items.every((e) => e is Map && e.containsKey('id'))) { + items.sort( + (a, b) => (a as Map)['id'].toString().compareTo( + (b as Map)['id'].toString(), + ), + ); + } + return items; + } + if (value is String && + const { + 'civilTime', + 'startedAt', + 'finishedAt', + 'createdAt', + 'updatedAt', + }.contains(field)) { + return DateTime.parse(value).toUtc().toIso8601String(); + } + return value; + } + + return canonical(value)!; +} + +DateTime _templateInstant(Object? value, String field) { + final parsed = BackupValueValidation.instant(_asString(value, field)); + if (parsed.microsecondsSinceEpoch % Duration.microsecondsPerSecond != 0) { + throw FormatException('$field has unsupported subsecond precision'); + } + return parsed; +} diff --git a/lib/core/backup/backup_crypto.dart b/lib/core/backup/backup_crypto.dart index 78a2fc5b..a9749d2f 100644 --- a/lib/core/backup/backup_crypto.dart +++ b/lib/core/backup/backup_crypto.dart @@ -1,14 +1,15 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; - import 'package:on_time_front/core/backup/backup_password.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; import 'package:sodium/sodium_sumo.dart'; +import 'backup_limits.dart'; +import 'backup_json_reader.dart'; class BackupCrypto { BackupCrypto({Future Function()? sodiumLoader}) : _sodiumLoader = sodiumLoader ?? (() async => SodiumSumoInit.init()); - static const _magic = 'ONTIMEBK'; static const formatVersion = 1; static const opsLimit = 3; @@ -19,126 +20,278 @@ class BackupCrypto { static const _maxFrameBytes = chunkSize + 1024; final Future Function() _sodiumLoader; + /// Explicit memory-input compatibility adapter. Mobile picker/export paths + /// use the streams and never join a whole container here. Future encrypt({ required Uint8List plaintext, required String password, }) async { - final normalizedPassword = BackupPassword.parse(password); + final output = BytesBuilder(copy: false); + await for (final bytes in encryptStream( + plaintext: Stream.value(plaintext), + plaintextLength: plaintext.length, + password: password, + )) { + output.add(bytes); + } + return output.takeBytes(); + } + + Future decrypt({ + required Uint8List container, + required String password, + }) async { + final output = BytesBuilder(copy: false); + await for (final bytes in decryptStream( + container: Stream.value(container), + password: password, + )) { + output.add(bytes); + } + return output.takeBytes(); + } + + Stream encryptStream({ + required Stream> plaintext, + required int plaintextLength, + required String password, + BackupBudget? budget, + }) async* { + budget ??= BackupBudget(); + budget.lease?.check(); + if (plaintextLength < 0 || plaintextLength > BackupLimits.plainBytes) { + BackupLimits.exceeded('plainBytes'); + } + final prepared = BackupPassword.parse(password); final sodium = await _sodiumLoader(); final salt = sodium.randombytes.buf(16); - final header = utf8.encode( - jsonEncode({ - 'formatVersion': formatVersion, - 'suite': 'argon2id13+xchacha20poly1305-secretstream', - 'opsLimit': opsLimit, - 'memLimit': memLimit, - 'salt': base64UrlEncode(salt), - 'chunkSize': chunkSize, - }), + final header = Uint8List.fromList( + utf8.encode( + jsonEncode({ + 'formatVersion': formatVersion, + 'suite': 'argon2id13+xchacha20poly1305-secretstream', + 'opsLimit': opsLimit, + 'memLimit': memLimit, + 'salt': base64UrlEncode(salt), + 'chunkSize': chunkSize, + }), + ), ); - final key = await _deriveKey(sodium, normalizedPassword, salt); + SecureKey? key; try { - final chunks = [ - for (var offset = 0; offset < plaintext.length; offset += chunkSize) - Uint8List.sublistView( - plaintext, - offset, - (offset + chunkSize).clamp(0, plaintext.length), - ), - ]; - if (chunks.isEmpty) chunks.add(Uint8List(0)); - final encrypted = await sodium.crypto.secretStream - .pushEx( - key: key, - messageStream: Stream.fromIterable([ - for (final (index, chunk) in chunks.indexed) - SecretStreamPlainMessage( - chunk, - additionalData: Uint8List.fromList(header), - tag: index == chunks.length - 1 - ? SecretStreamMessageTag.finalPush - : SecretStreamMessageTag.message, - ), - ]), - ) - .toList(); - - final builder = BytesBuilder(copy: false) + key = await _deriveKey(sodium, prepared, salt); + budget.lease?.check(); + final chunks = plaintextLength == 0 + ? 1 + : (plaintextLength + chunkSize - 1) ~/ chunkSize; + final frameCount = chunks + 1; // secretstream header is the first frame. + final prefix = BytesBuilder(copy: false) ..add(ascii.encode(_magic)) ..add(_uint32(header.length)) ..add(header) - ..add(_uint32(encrypted.length)); - for (final frame in encrypted) { - builder - ..add(_uint32(frame.message.length)) - ..add(frame.message); + ..add(_uint32(frameCount)); + final prefixBytes = prefix.takeBytes(); + budget.ciphertext(prefixBytes.length); + yield prefixBytes; + var actualFrames = 0; + await for (final frame in sodium.crypto.secretStream.pushEx( + key: key, + messageStream: _messages(plaintext, plaintextLength, header, budget), + )) { + if (++actualFrames > frameCount || + frame.message.length > _maxFrameBytes) { + BackupLimits.invalid(); + } + budget.ciphertext(4 + frame.message.length); + budget.buffer(frame.message.length); + yield _uint32(frame.message.length); + yield frame.message; } - return builder.takeBytes(); + if (actualFrames != frameCount) BackupLimits.invalid(); } finally { - key.dispose(); - salt.fillRange(0, salt.length, 0); + try { + key?.dispose(); + } finally { + salt.fillRange(0, salt.length, 0); + } } } - Future decrypt({ - required Uint8List container, - required String password, - }) async { - final normalizedPassword = BackupPassword.parse(password); - final reader = _ByteReader(container); - if (ascii.decode(reader.read(_magic.length)) != _magic) { - throw const FormatException('Not an OnTime backup file.'); - } - final headerLength = reader.readUint32(); - if (headerLength <= 0 || headerLength > _maxHeaderBytes) { - throw const FormatException('Unsafe backup header length.'); - } - final header = reader.read(headerLength); - final metadata = jsonDecode(utf8.decode(header)); - if (metadata is! Map) { - throw const FormatException('Invalid backup header.'); + Stream _messages( + Stream> source, + int expected, + Uint8List header, + BackupBudget budget, + ) async* { + final iterator = StreamIterator(_chunks(source, budget)); + try { + var has = await iterator.moveNext(); + if (!has) { + if (expected != 0) BackupLimits.invalid(); + yield SecretStreamPlainMessage( + Uint8List(0), + additionalData: header, + tag: SecretStreamMessageTag.finalPush, + ); + return; + } + var seen = 0; + while (has) { + final current = iterator.current; + seen += current.length; + if (seen > expected) BackupLimits.invalid(); + // Look ahead before emitting the final tag; no tail can be hidden by a + // crypto transformer cancelling its source after finalPush. + has = await iterator.moveNext(); + if (!has && seen != expected) BackupLimits.invalid(); + yield SecretStreamPlainMessage( + current, + additionalData: header, + tag: has + ? SecretStreamMessageTag.message + : SecretStreamMessageTag.finalPush, + ); + } + } finally { + await iterator.cancel(); } - _validateHeader(metadata); - final salt = Uint8List.fromList( - base64Url.decode(metadata['salt'] as String), - ); - if (salt.length != 16) throw const FormatException('Invalid backup salt.'); + } - final frameCount = reader.readUint32(); - if (frameCount < 2 || frameCount > _maxFrameCount) { - throw const FormatException('Unsafe backup frame count.'); - } - final frames = []; - for (var index = 0; index < frameCount; index++) { - final length = reader.readUint32(); - if (length <= 0 || length > _maxFrameBytes) { - throw const FormatException('Unsafe backup frame length.'); + Stream _chunks( + Stream> source, + BackupBudget budget, + ) async* { + var pending = Uint8List(chunkSize); + var used = 0; + await for (final bytes in source) { + budget.plaintext(bytes.length); + var offset = 0; + while (offset < bytes.length) { + final count = (bytes.length - offset).clamp(0, chunkSize - used); + pending.setRange(used, used + count, bytes, offset); + used += count; + offset += count; + if (used == chunkSize) { + budget.buffer(chunkSize * 2); + yield pending; + pending = Uint8List(chunkSize); + used = 0; + } } - frames.add(reader.read(length)); } - if (!reader.isAtEnd) throw const FormatException('Unexpected backup data.'); + if (used != 0) yield Uint8List.sublistView(pending, 0, used); + } - final sodium = await _sodiumLoader(); - final key = await _deriveKey(sodium, normalizedPassword, salt); + Stream decryptStream({ + required Stream> container, + required String password, + BackupBudget? budget, + }) async* { + budget ??= BackupBudget(); + final normalizedPassword = BackupPassword.parse(password); + final reader = _BackupStreamReader(container, budget); + SecureKey? key; + Uint8List? salt; + Object? originalError; try { - final decrypted = await sodium.crypto.secretStream - .pullEx( - key: key, - cipherStream: Stream.fromIterable([ - SecretStreamCipherMessage(frames.first), - for (final frame in frames.skip(1)) - SecretStreamCipherMessage(frame, additionalData: header), - ]), - ) - .toList(); - return Uint8List.fromList([ - for (final message in decrypted) ...message.message, - ]); - } catch (_) { - throw const FormatException('Wrong password or damaged backup file.'); + if (ascii.decode(await reader.read(_magic.length)) != _magic) { + BackupLimits.invalid(); + } + final length = await reader.uint32(); + if (length <= 0 || length > _maxHeaderBytes) BackupLimits.invalid(); + final header = await reader.read(length); + // The envelope header is small (at most 4 KiB), but still untrusted JSON: + // reject duplicate keys/deep nesting before KDF just like portable input. + final headerSink = _HeaderSink(); + final headerNode = await BackupJsonReader( + headerSink, + budget, + ).read(Stream.value(header)); + final metadata = headerSink.nodes[headerNode]; + if (metadata is! Map) BackupLimits.invalid(); + _validateHeader(metadata); + salt = Uint8List.fromList(base64Url.decode(metadata['salt'] as String)); + if (salt.length != 16) BackupLimits.invalid(); + final frameCount = await reader.uint32(); + if (frameCount < 2 || frameCount > _maxFrameCount) BackupLimits.invalid(); + final sodium = await _sodiumLoader(); + key = await _deriveKey(sodium, normalizedPassword, salt); + budget.lease?.check(); + var consumed = 0; + var finalized = false; + Stream frames() async* { + for (var i = 0; i < frameCount; i++) { + if (finalized) { + throw const BackupProcessingFailure( + BackupFailureKind.authentication, + ); + } + final size = await reader.uint32(); + if (size <= 0 || size > _maxFrameBytes) BackupLimits.invalid(); + final bytes = await reader.read(size); + consumed++; + yield SecretStreamCipherMessage( + bytes, + additionalData: i == 0 ? null : header, + ); + } + } + + try { + await for (final message in sodium.crypto.secretStream.pullEx( + key: key, + cipherStream: frames(), + )) { + budget.plaintext(message.message.length); + finalized = message.tag == SecretStreamMessageTag.finalPush; + // An authenticated final must also be the declared last frame. + // Reject here before sodium receives another frame after finalPush + // and exposes a transformer StateError for an untrusted container. + if (finalized && consumed != frameCount) { + throw const BackupProcessingFailure( + BackupFailureKind.authentication, + ); + } + yield message.message; + } + } catch (error) { + if (error is SodiumException || + error is StreamClosedEarlyException || + error is InvalidHeaderException) { + throw const BackupProcessingFailure(BackupFailureKind.authentication); + } + rethrow; + } + if (!finalized || consumed != frameCount) { + throw const BackupProcessingFailure(BackupFailureKind.authentication); + } + await reader.requireEnd(); + } on FormatException catch (error) { + if (error is BackupProcessingFailure) { + originalError = error; + rethrow; + } + const typed = BackupProcessingFailure(BackupFailureKind.dataInvariant); + originalError = typed; + throw typed; + } catch (error) { + originalError = error; + rethrow; } finally { - key.dispose(); - salt.fillRange(0, salt.length, 0); + try { + await reader.close(); + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: originalError, + cleanupError: cleanup, + ); + } finally { + try { + key?.dispose(); + } finally { + salt?.fillRange(0, salt.length, 0); + } + } } } @@ -163,15 +316,23 @@ class BackupCrypto { } void _validateHeader(Map header) { - if (header['formatVersion'] != formatVersion) { - throw const FormatException('Unsupported backup format version.'); + if (header['formatVersion'] is! int || + header['formatVersion'] != formatVersion) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); } - if (header['suite'] != 'argon2id13+xchacha20poly1305-secretstream' || + if (header['opsLimit'] is! int || + header['memLimit'] is! int || + header['chunkSize'] is! int || + header['suite'] != 'argon2id13+xchacha20poly1305-secretstream' || header['opsLimit'] != opsLimit || header['memLimit'] != memLimit || header['chunkSize'] != chunkSize || header['salt'] is! String) { - throw const FormatException('Unsupported or unsafe backup crypto suite.'); + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); } } @@ -181,25 +342,79 @@ class BackupCrypto { } } -class _ByteReader { - _ByteReader(this._bytes); - - final Uint8List _bytes; +class _BackupStreamReader { + _BackupStreamReader(Stream> source, this.budget) + : _input = StreamIterator(source); + final StreamIterator> _input; + final BackupBudget budget; + List _chunk = const []; int _offset = 0; + bool _done = false; - bool get isAtEnd => _offset == _bytes.length; + Future _available() async { + while (_offset == _chunk.length && !_done) { + budget.lease?.check(); + if (!await _input.moveNext()) { + _done = true; + break; + } + _chunk = _input.current; + _offset = 0; + budget.ciphertext(_chunk.length); + } + return _offset < _chunk.length; + } - Uint8List read(int length) { - if (length < 0 || _offset + length > _bytes.length) { - throw const FormatException('Truncated backup file.'); + Future read(int size) async { + if (size < 0 || size > BackupCrypto._maxFrameBytes) BackupLimits.invalid(); + final result = Uint8List(size); + budget.buffer(size); + var filled = 0; + while (filled < size) { + if (!await _available()) { + throw const BackupProcessingFailure(BackupFailureKind.authentication); + } + final count = (_chunk.length - _offset).clamp(0, size - filled); + result.setRange(filled, filled + count, _chunk, _offset); + _offset += count; + filled += count; } - final result = Uint8List.sublistView(_bytes, _offset, _offset + length); - _offset += length; return result; } - int readUint32() { - final value = ByteData.sublistView(read(4)).getUint32(0, Endian.big); - return value; + Future uint32() async => + ByteData.sublistView(await read(4)).getUint32(0, Endian.big); + Future requireEnd() async { + if (await _available()) { + throw const BackupProcessingFailure(BackupFailureKind.authentication); + } + } + + Future close() => _input.cancel(); +} + +// The entire header is already bounded to 4096 bytes before this sink exists. +// This adapter must never be used for the portable payload. +class _HeaderSink implements BackupJsonSink { + final nodes = {}; + @override + int writeNode(int? parent, String? key, String kind, Object? scalar) { + final dynamic value = kind == 'object' + ? {} + : kind == 'array' + ? [] + : scalar; + final id = nodes.length + 1; + if (parent != null) { + final target = nodes[parent]; + if (target is Map) { + if (target.containsKey(key)) BackupLimits.invalid(); + target[key!] = value; + } else { + (target as List).add(value); + } + } + nodes[id] = value; + return id; } } diff --git a/lib/core/backup/backup_export_snapshot.dart b/lib/core/backup/backup_export_snapshot.dart new file mode 100644 index 00000000..c59c0120 --- /dev/null +++ b/lib/core/backup/backup_export_snapshot.dart @@ -0,0 +1,696 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'backup_limits.dart'; +import 'restore_staging.dart'; + +/// A point-in-time, encrypted, immutable source for both export encoding passes. +/// The caller owns the source writer gate only until [create] returns. Neither +/// pass reads the active database. No complete portable payload is collected. +final class BackupExportSnapshot { + BackupExportSnapshot._( + this._staging, + this.cutoff, + this.sourceAppVersion, + this.sourcePlatform, + this.budget, + this._profile, + ); + + final RestoreStaging _staging; + final DateTime cutoff; + final String sourceAppVersion; + final String sourcePlatform; + final BackupBudget budget; + final Map _profile; + bool _released = false; + bool _encoding = false; + AppDatabase get _database => _staging.database; + int get dataRevision => _integer(_profile['data_revision']); + + static Future create( + AppDatabase source, { + required DateTime cutoff, + required String sourceAppVersion, + required String sourcePlatform, + required BackupBudget budget, + RestoreStagingFactory? stagingFactory, + }) async { + budget.lease?.check(); + BackupLimits.string(sourceAppVersion); + BackupLimits.string(sourcePlatform); + if (cutoff.year < 1 || cutoff.year > 9999) BackupLimits.invalid(); + final staging = await (stagingFactory ?? RestoreStaging.create)(); + try { + await source.transaction(() async { + await copyPortableBackupRows(source, staging.database, budget: budget); + }); + // No public mutable database handle escapes. The engine also refuses + // accidental writes during the two separate serializer traversals. + await staging.database.customStatement('PRAGMA query_only = ON'); + Map? profile; + await for (final row in _rows(staging.database, _tables.first, budget)) { + if (profile != null || row['id'] != localProfileId) { + BackupLimits.invalid(); + } + profile = row; + } + if (profile == null) BackupLimits.invalid(); + BackupLimits.integer( + profile['data_revision'], + maximum: BackupLimits.exactInteger - 1, + ); + return BackupExportSnapshot._( + staging, + cutoff.toUtc(), + sourceAppVersion, + sourcePlatform, + budget, + profile, + ); + } catch (original) { + try { + await staging.release(); + } catch (cleanup) { + budget.lease?.retainCleanup(staging.release); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + /// Deterministic JSON/UTF-8 fragments. The integration layer counts/hashes and + /// validates pass one, then verifies identical length/digest during encryption. + /// It alone charges whole plaintext size, avoiding double charging two passes. + Stream> plaintext() async* { + if (_released || _encoding) throw StateError('Snapshot is unavailable'); + _encoding = true; + // Coalesce tiny JSON punctuation/fields into a fixed-size output buffer. + // A full payload is never collected, and yielded buffers are never reused. + var output = Uint8List(16384); + var filled = 0; + budget.buffer(output.length); + try { + await for (final fragment in _json()) { + budget.lease?.check(); + // Split JSON text without separating a UTF-16 surrogate pair. A piece + // is at most 4096 UTF-16 units, hence at most 16384 UTF-8 bytes. + for (var start = 0; start < fragment.length;) { + var end = (start + 4096).clamp(0, fragment.length); + if (end < fragment.length) { + final last = fragment.codeUnitAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end--; + } + final bytes = utf8.encode(fragment.substring(start, end)); + budget.buffer(bytes.length); + for (var at = 0; at < bytes.length;) { + final count = (bytes.length - at).clamp(0, output.length - filled); + output.setRange(filled, filled + count, bytes, at); + filled += count; + at += count; + if (filled == output.length) { + yield output; + budget.lease?.check(); + output = Uint8List(16384); + filled = 0; + } + } + start = end; + } + } + if (filled != 0) yield Uint8List.sublistView(output, 0, filled); + } finally { + _encoding = false; + } + } + + Stream _json() async* { + yield '{"formatVersion":2,"cutoff":'; + yield* _value(cutoff.toIso8601String()); + yield ',"sourceAppVersion":'; + yield* _value(sourceAppVersion); + yield ',"sourcePlatform":'; + yield* _value(sourcePlatform); + yield ',"dataRevision":'; + yield* _value(dataRevision); + yield ',"profile":'; + yield* _value({ + 'spareTimeMinutes': _profile['spare_time'], + 'note': _profile['note'], + 'isOnboardingCompleted': _boolean(_profile['is_onboarding_completed']), + 'eligibleOutcomeCount': _profile['eligible_outcome_count'], + 'onTimeOutcomeCount': _profile['on_time_outcome_count'], + }); + yield ',"preferences":'; + yield* _value({ + 'alarmsEnabled': _boolean(_profile['alarms_enabled']), + 'alarmOffsetMinutes': _profile['alarm_offset_minutes'], + 'detailedNotificationContent': _boolean( + _profile['detailed_notification_content'], + ), + }); + yield ',"defaultPreparation":'; + yield* _linkedPreparation('preparation_users', 'user_id', localProfileId); + yield ',"schedules":['; + var first = true; + await for (final row in _schedulesWithPlaces()) { + if (!first) yield ','; + first = false; + if (row['_place_name'] is! String) BackupLimits.invalid(); + yield* _value( + _schedule(row, { + 'id': row['place_id'], + 'place_name': row['_place_name'], + }), + ); + } + yield '],"schedulePreparations":{'; + first = true; + await for (final row in _tableRows('schedules')) { + if (!first) yield ','; + first = false; + yield* _value(row['id']); + yield ':'; + yield* _linkedPreparation( + 'preparation_schedules', + 'schedule_id', + row['id'], + ); + } + yield '},"templates":['; + first = true; + await for (final row in _tableRows('preparation_templates')) { + if (!first) yield ','; + first = false; + yield '{"id":'; + yield* _value(row['id']); + yield ',"name":'; + yield* _value(row['template_name']); + yield ',"createdAt":'; + yield* _value(_instant(row['created_at'])); + yield ',"updatedAt":'; + yield* _value(_instant(row['updated_at'])); + yield ',"preparation":'; + yield* _templatePreparation(row['id']); + yield '}'; + } + yield '],"recurring":{'; + first = true; + for (final entry in const { + 'definitions': 'preparation_definitions', + 'steps': 'preparation_definition_steps', + 'segments': 'recurring_schedule_segments', + 'exclusions': 'recurring_schedule_exclusions', + }.entries) { + if (!first) yield ','; + first = false; + yield '${jsonEncode(entry.key)}:['; + var firstRow = true; + await for (final row in _tableRows(entry.value)) { + if (!firstRow) yield ','; + firstRow = false; + yield* _value(_recurring(entry.key, row)); + } + yield ']'; + } + yield '}}'; + } + + Stream> _tableRows( + String table, { + String? where, + List arguments = const [], + }) => _rows( + _database, + _tables.firstWhere((t) => t.name == table), + budget, + where: where, + arguments: arguments, + ); + + // The snapshot was scalar-checked before copy and is now query-only. Join + // this bounded page directly rather than issuing a place query per schedule. + Stream> _schedulesWithPlaces() async* { + final columns = _tables + .firstWhere((t) => t.name == 'schedules') + .columns + .keys; + int? cursor; + while (true) { + budget.lease?.check(); + final rows = await _database + .customSelect( + 'SELECT s.rowid AS _cursor,${columns.map((c) => 's."$c"').join(',')},' + 'p.place_name AS _place_name FROM schedules s LEFT JOIN places p ON p.id=s.place_id ' + '${cursor == null ? '' : 'WHERE s.rowid > ? '}ORDER BY s.rowid LIMIT 16', + variables: [if (cursor != null) Variable(cursor)], + ) + .get(); + if (rows.isEmpty) return; + for (final row in rows) { + budget.visit(); + cursor = row.read('_cursor'); + yield row.data; + } + } + } + + Stream _linkedPreparation( + String table, + String ownerColumn, + Object? owner, + ) async* { + yield '['; + var count = 0; + await for (final row in _tableRows( + table, + where: '$ownerColumn = ?', + arguments: [owner], + )) { + if (++count > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + if (count > 1) yield ','; + // Preserve raw edges. The shared validator must reject, not silently fix, + // a cycle, multiple heads, disconnected graph or cross-owner reference. + yield* _value({ + 'id': row['id'], + 'name': row['preparation_name'], + 'minutes': row['preparation_time'], + 'nextId': row['next_preparation_id'], + }); + } + yield ']'; + } + + Stream _templatePreparation(Object? owner) async* { + // A definition is bounded at 1000 steps. Query 1001 before materializing to + // detect overflow, with every source scalar bounded by the snapshot copy. + final rows = await _database + .customSelect( + 'SELECT id,preparation_name,preparation_time,position FROM preparation_template_steps ' + 'WHERE template_id=? ORDER BY position,id LIMIT ${BackupLimits.preparationSteps + 1}', + variables: [Variable(owner as String)], + ) + .get(); + budget.visit(rows.length); + if (rows.length > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + yield '['; + for (var i = 0; i < rows.length; i++) { + final row = rows[i].data; + if (row['position'] != i) BackupLimits.invalid(); + if (i > 0) yield ','; + yield* _value({ + 'id': row['id'], + 'name': row['preparation_name'], + 'minutes': row['preparation_time'], + 'nextId': i + 1 < rows.length ? rows[i + 1].data['id'] : null, + }); + } + yield ']'; + } + + Stream _value(Object? value) async* { + budget.visit(); + if (value is Map) { + yield '{'; + var first = true; + for (final entry in value.entries) { + if (!first) yield ','; + first = false; + // These keys are fixed schema literals constructed above, not input + // nodes. Charge the actual value traversal, not a second fake data node + // for every constant field name. Dynamic schedule-owner keys still use + // _value in _json and are charged normally. + yield jsonEncode(entry.key); + yield ':'; + yield* _value(entry.value); + } + yield '}'; + } else { + if (value is String) BackupLimits.string(value); + if (value != null && + value is! String && + value is! int && + value is! bool) { + BackupLimits.invalid(); + } + yield jsonEncode(value); + } + } + + Map _schedule( + Map r, + Map place, + ) => { + 'id': r['id'], + 'place': {'id': place['id'], 'name': place['place_name']}, + 'name': r['schedule_name'], + 'civilTime': r['schedule_time'], + 'timeZoneId': r['time_zone_id'], + 'occurrenceOffsetSeconds': r['occurrence_offset_seconds'], + 'moveTimeMinutes': _durationMinutes(r['move_time']), + 'isChanged': _boolean(r['is_changed']), + 'spareTimeMinutes': _durationMinutes(r['schedule_spare_time']), + 'note': r['schedule_note'] ?? '', + 'latenessTime': r['lateness_time'], + 'doneStatus': r['done_status'], + 'finishedAt': _instant(r['finished_at']), + 'startedAt': _instant(r['started_at']), + 'preparationFrozen': _boolean(r['preparation_frozen']), + 'preparationMode': r['preparation_mode'], + 'preparationTemplateId': r['preparation_template_id'], + 'preparationTemplateName': r['preparation_template_name'], + 'preparationTemplateDeleted': _boolean(r['preparation_template_deleted']), + 'scoreContributionRecorded': _boolean(r['score_contribution_recorded']), + 'recurringSegmentId': r['recurring_segment_id'], + 'recurringSlotKey': r['recurring_slot_key'], + 'recurringOrdinal': r['recurring_ordinal'], + 'recurringOverrides': r['recurring_overrides'], + 'preparationDefinitionId': r['preparation_definition_id'], + }; + + Map _recurring(String kind, Map r) => + switch (kind) { + 'definitions' => { + 'id': r['id'], + 'ownerId': r['owner_id'], + 'scope': r['scope'], + 'name': r['name'], + 'createdAt': _millis(r['created_at']), + }, + 'steps' => { + 'id': r['id'], + 'definitionId': r['definition_id'], + 'name': r['name'], + 'minutes': r['minutes'], + 'position': r['position'], + }, + 'segments' => { + 'id': r['id'], + 'seriesId': r['series_id'], + 'ruleJson': r['rule_json'], + 'scheduleJson': r['schedule_json'], + 'preparationId': r['preparation_id'], + 'fromSlot': r['from_slot'], + 'beforeSlot': r['before_slot'], + 'createdAt': _millis(r['created_at']), + 'preparationNotBefore': _millis(r['preparation_not_before']), + }, + 'exclusions' => { + 'segmentId': r['segment_id'], + 'slotKey': r['slot_key'], + 'ordinal': r['ordinal'], + }, + _ => throw StateError('Unknown portable table'), + }; + + Future release() async { + if (_released) return; + if (_encoding) { + throw StateError('Encoder must terminate before releasing snapshot'); + } + await _staging.release(); + _released = true; + } +} + +int _integer(Object? value) { + if (value is! int) BackupLimits.invalid(); + return value; +} + +bool _boolean(Object? value) { + if (value != 0 && value != 1) BackupLimits.invalid(); + return value == 1; +} + +int? _durationMinutes(Object? value) { + if (value == null) return null; + // Only these two schedule columns store milliseconds. Format 1/2 historically + // exports Duration.inMinutes (truncating a positive sub-minute remainder). + // Check the original value before truncation so a negative millisecond cannot + // become an apparently valid zero, and avoid Duration's microsecond overflow. + final milliseconds = BackupLimits.integer( + value, + maximum: 0x7fffffffffffffff ~/ 1000, + ); + return milliseconds ~/ 60000; +} + +int? _millis(Object? value) { + if (value == null) return null; + final seconds = _integer(value); + if (seconds < -62135596800 || seconds > 253402300799) BackupLimits.invalid(); + return seconds * 1000; +} + +String? _instant(Object? value) { + final millis = _millis(value); + return millis == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + millis, + isUtc: true, + ).toIso8601String(); +} + +/// Copies only the existing portable fields into an already empty target. +/// The caller owns source consistency, target clearing and all +/// revision, recovery, delivery or activation authority. This helper does not +/// acquire gates, delete data, increment revisions or copy installation metadata. +Future copyPortableBackupRows( + AppDatabase source, + AppDatabase target, { + required BackupBudget budget, +}) { + // Drift keeps one current connection in a zone. Entering the target's + // transaction hides the source transaction, so source reads must explicitly + // run in the caller's zone. Otherwise they queue behind the source transaction + // that is itself waiting for this copy to finish. + final sourceZone = Zone.current; + return target.transaction(() async { + // Linked preparation rows may precede their successor; enforce their foreign + // keys at the end of the caller's transaction, never disable enforcement. + await target.customStatement('PRAGMA defer_foreign_keys = ON'); + var copied = 0; + var schedules = 0; + for (final table in _tables) { + final existing = await target + .customSelect('SELECT 1 FROM "${table.name}" LIMIT 1') + .get(); + if (existing.isNotEmpty) { + throw StateError('Portable copy target must be empty'); + } + } + for (final table in _tables) { + final columns = table.columns.keys.toList(growable: false); + final sql = + 'INSERT INTO "${table.name}" (${columns.map((c) => '"$c"').join(',')}) ' + 'VALUES (${List.filled(columns.length, '?').join(',')})'; + await for (final row in _rows( + source, + table, + budget, + queryZone: sourceZone, + )) { + if (++copied > BackupLimits.records) BackupLimits.exceeded('records'); + if (table.name == 'schedules' && ++schedules > BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + await target.customStatement(sql, [ + for (final column in columns) row[column], + ]); + } + } + final foreign = await target + .customSelect('SELECT * FROM pragma_foreign_key_check LIMIT 1') + .get(); + if (foreign.isNotEmpty) BackupLimits.invalid(); + }); +} + +final class _PortableTable { + const _PortableTable(this.name, this.columns); + final String name; + // null means an SQLite integer; text limits apply before a cell crosses FFI. + final Map columns; +} + +const _id = BackupLimits.identifierBytes; +const _text = BackupLimits.stringBytes; +// Existing domain name limits are 30 UTF-16 units. This byte preflight is only +// an allocation guard; the shared validator enforces the exact name semantics. +const _shortName = 30 * 4; +const _tables = [ + _PortableTable('users', { + 'id': _id, + 'spare_time': null, + 'note': _text, + 'is_onboarding_completed': null, + 'eligible_outcome_count': null, + 'on_time_outcome_count': null, + 'alarms_enabled': null, + 'alarm_offset_minutes': null, + 'detailed_notification_content': null, + 'data_revision': null, + }), + _PortableTable('places', {'id': _id, 'place_name': _shortName}), + _PortableTable('preparation_users', { + 'id': _id, + 'user_id': _id, + 'preparation_name': _shortName, + 'preparation_time': null, + 'next_preparation_id': _id, + }), + _PortableTable('preparation_templates', { + 'id': _id, + 'template_name': _shortName, + 'created_at': null, + 'updated_at': null, + }), + _PortableTable('preparation_template_steps', { + 'id': _id, + 'template_id': _id, + 'preparation_name': _shortName, + 'preparation_time': null, + 'position': null, + }), + _PortableTable('preparation_definitions', { + 'id': _id, + 'owner_id': _id, + 'scope': _text, + 'name': _text, + 'created_at': null, + }), + _PortableTable('preparation_definition_steps', { + 'id': _id, + 'definition_id': _id, + 'name': _shortName, + 'minutes': null, + 'position': null, + }), + _PortableTable('recurring_schedule_segments', { + 'id': _id, + 'series_id': _id, + 'rule_json': _text, + 'schedule_json': _text, + 'preparation_id': _id, + 'from_slot': _text, + 'before_slot': _text, + 'created_at': null, + 'preparation_not_before': null, + }), + _PortableTable('recurring_schedule_exclusions', { + 'segment_id': _id, + 'slot_key': _text, + 'ordinal': null, + }), + _PortableTable('schedules', { + 'id': _id, + 'place_id': _id, + 'schedule_name': _text, + 'time_zone_id': _id, + 'occurrence_offset_seconds': null, + 'schedule_time': _text, + 'move_time': null, + 'is_changed': null, + 'schedule_spare_time': null, + 'schedule_note': _text, + 'lateness_time': null, + 'done_status': _text, + 'started_at': null, + 'finished_at': null, + 'preparation_mode': _text, + 'preparation_template_id': _id, + 'preparation_template_name': _text, + 'preparation_template_deleted': null, + 'preparation_frozen': null, + 'score_contribution_recorded': null, + 'recurring_segment_id': _id, + 'recurring_slot_key': _text, + 'recurring_ordinal': null, + 'recurring_overrides': _text, + 'preparation_definition_id': _id, + }), + _PortableTable('preparation_schedules', { + 'id': _id, + 'schedule_id': _id, + 'preparation_name': _shortName, + 'preparation_time': null, + 'next_preparation_id': _id, + }), +]; + +/// Keyset pagination includes negative/zero rowids on the first page. CASE +/// suppresses malformed/oversized cells before they can be copied into Dart. +/// The UTF-8 byte length is evaluated only after a bounded character length. +Stream> _rows( + AppDatabase db, + _PortableTable table, + BackupBudget budget, { + String? where, + List arguments = const [], + Zone? queryZone, +}) async* { + final projections = ['rowid AS "_cursor"']; + var index = 0; + for (final entry in table.columns.entries) { + final column = '"${entry.key}"'; + final max = entry.value; + final guard = max == null + ? "CASE WHEN $column IS NULL OR typeof($column)='integer' THEN 0 ELSE 2 END" + : "CASE WHEN $column IS NULL THEN 0 WHEN typeof($column)<>'text' THEN 2 " + "WHEN length($column)>$max THEN 1 WHEN length(CAST($column AS BLOB))>$max THEN 1 ELSE 0 END"; + projections.add( + 'CASE WHEN ($guard)=0 THEN $column ELSE NULL END AS $column', + ); + projections.add('($guard) AS "_guard${index++}"'); + } + int? cursor; + while (true) { + budget.lease?.check(); + final predicates = [ + if (where != null) '($where)', + if (cursor != null) 'rowid > ?', + ]; + final rows = await (queryZone ?? Zone.current).run( + () => db + .customSelect( + 'SELECT ${projections.join(',')} FROM "${table.name}" ' + '${predicates.isEmpty ? '' : 'WHERE ${predicates.join(' AND ')} '}ORDER BY rowid LIMIT 16', + variables: [ + for (final v in [...arguments, ?cursor]) + if (v is int) + Variable(v) + else if (v is String) + Variable(v) + else + throw ArgumentError( + 'Portable query requires an integer or string argument', + ), + ], + ) + .get(), + ); + if (rows.isEmpty) return; + for (final row in rows) { + budget.visit(); + for (var i = 0; i < table.columns.length; i++) { + final flag = row.read('_guard$i'); + if (flag == 1) BackupLimits.exceeded('sourceScalar'); + if (flag != 0) BackupLimits.invalid(); + } + cursor = row.read('_cursor'); + yield {for (final name in table.columns.keys) name: row.data[name]}; + } + } +} diff --git a/lib/core/backup/backup_file_export_port.dart b/lib/core/backup/backup_file_export_port.dart index f6983129..804be2bc 100644 --- a/lib/core/backup/backup_file_export_port.dart +++ b/lib/core/backup/backup_file_export_port.dart @@ -1,41 +1,190 @@ +import 'dart:async'; +import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; - -enum BackupFileExportReceipt { saved, cancelled } - -/// A receipt contains no user-selected path or persistent document permission. -abstract interface class BackupFileExportPort { - Future export({ - required Uint8List encryptedBytes, - required String suggestedName, - }); -} +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/ports/backup_file_export_port.dart'; +import 'backup_limits.dart'; +export 'package:on_time_front/domain/ports/backup_file_export_port.dart'; final class NativeBackupFileExportPort implements BackupFileExportPort { const NativeBackupFileExportPort(); - static const channel = MethodChannel('ontime/backup_export'); @override - Future export({ - required Uint8List encryptedBytes, + Future exportStream({ + required Stream> encrypted, required String suggestedName, + BackupProcessingLease? lease, }) async { - final result = await channel.invokeMethod('export', { - 'encryptedBytes': encryptedBytes, + final effectiveLease = lease ?? BackupProcessingOwner.shared.acquire(); + effectiveLease.beginOperation(); + try { + return await _export( + encrypted: encrypted, + suggestedName: suggestedName, + lease: effectiveLease, + ); + } finally { + if (lease == null && + effectiveLease.phase != BackupProcessingPhase.cleanupPending) { + effectiveLease.release(); + } + effectiveLease.endOperation(); + } + } + + Future _export({ + required Stream> encrypted, + required String suggestedName, + required BackupProcessingLease lease, + }) async { + final handle = await _invoke('begin', { 'suggestedName': suggestedName, }); - return switch (result) { - 'saved' => BackupFileExportReceipt.saved, - 'cancelled' => BackupFileExportReceipt.cancelled, - _ => throw const BackupFileExportFailure(), - }; + if (handle == null || handle.isEmpty) throw const BackupFileExportFailure(); + final attempt = _NativeExportAttempt(handle); + lease.cancellationRequested.then((_) async { + try { + await attempt.cancel(); + } catch (_) { + /* Actual finally/owner retains retry. */ + } + }); + Object? original; + var saved = false; + BackupFileExportReceipt? receipt; + try { + final digest = _DigestSink(); + final hash = sha256.startChunkedConversion(digest); + var length = 0; + var sequence = 0; + await for (final chunk in encrypted) { + lease.check(); + // Crypto frames and channel messages have different boundaries. Only + // one <=64KiB message is in flight, with acknowledgement before reuse. + for (var at = 0; at < chunk.length; at += 65536) { + final end = (at + 65536).clamp(0, chunk.length); + final amount = end - at; + if (amount > BackupLimits.cipherBytes - length) { + BackupLimits.exceeded('cipherBytes'); + } + final bytes = Uint8List.fromList(chunk.sublist(at, end)); + final ack = await _invoke('append', { + 'handle': handle, + 'sequence': sequence, + 'bytes': bytes, + }); + if (ack != true) throw const BackupFileExportFailure(); + length += bytes.length; + hash.add(bytes); + sequence++; + lease.check(); + } + } + hash.close(); + lease.check(); + if (length == 0) throw const BackupFileExportFailure(); + final seal = await _invoke('seal', { + 'handle': handle, + 'length': length, + 'sha256': digest.value!.toString(), + }); + if (seal != true) throw const BackupFileExportFailure(); + lease.check(); + lease.update(BackupProcessingPhase.choosingDestination); + final result = await _invoke>('export', { + 'handle': handle, + }); + attempt.observe(result); + saved = attempt.outcome == 'saved'; + if (saved) { + receipt = BackupFileExportReceipt.saved; + } else if (attempt.outcome == 'cancelled') { + receipt = BackupFileExportReceipt.cancelled; + } else { + throw const BackupFileExportFailure(); + } + } catch (error) { + original = error; + } + // Cancellation may race the OS save acknowledgement. Its authoritative + // saved receipt survives cancellation and all later temporary cleanup errors. + try { + await attempt.cancel(); + saved = saved || attempt.outcome == 'saved'; + if (attempt.cleanupUnconfirmed) { + lease.retainCleanup(() async { + await attempt.cancel(); + if (attempt.cleanupUnconfirmed) throw const BackupFileExportFailure(); + }); + if (!saved) throw const BackupFileExportFailure(); + } + } catch (cleanup) { + lease.retainCleanup(() async { + await attempt.cancel(); + if (attempt.cleanupUnconfirmed) throw const BackupFileExportFailure(); + }); + if (!saved) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + } + if (saved) return BackupFileExportReceipt.saved; + if (original != null) { + Error.throwWithStackTrace(original, StackTrace.current); + } + return receipt ?? BackupFileExportReceipt.cancelled; } } -final class BackupFileExportFailure implements Exception { - const BackupFileExportFailure(); +Future _invoke(String method, Map args) async { + try { + return await NativeBackupFileExportPort.channel.invokeMethod( + method, + args, + ); + } on PlatformException catch (error) { + throw BackupProcessingFailure(switch (error.code) { + 'export_limit' => BackupFailureKind.resourceLimit, + 'export_cancelled' => BackupFailureKind.userCancelled, + _ => BackupFailureKind.inputOutput, + }, error.code == 'export_limit' ? 'cipherBytes' : null); + } +} + +class _NativeExportAttempt { + _NativeExportAttempt(this.handle); + final String handle; + String? outcome; + bool cleanupUnconfirmed = true; + Future? _cancelFlight; + void observe(Map? result) { + if (result == null || + !{'saved', 'cancelled', 'failed'}.contains(result['outcome']) || + result['cleanupUnconfirmed'] is! bool) { + throw const BackupFileExportFailure(); + } + if (outcome != 'saved') outcome = result['outcome'] as String; + cleanupUnconfirmed = result['cleanupUnconfirmed'] as bool; + } + + Future cancel() => + _cancelFlight ??= _cancel().whenComplete(() => _cancelFlight = null); + Future _cancel() async { + if (!cleanupUnconfirmed) return; + observe(await _invoke>('cancel', {'handle': handle})); + } +} + +class _DigestSink implements Sink { + Digest? value; + @override + void add(Digest data) { + value = data; + } @override - String toString() => - '백업 저장을 완료하지 못했습니다. 선택한 위치에 불완전한 파일이 남았는지 확인한 뒤 다시 시도해주세요.'; + void close() {} } diff --git a/lib/core/backup/backup_file_import_port.dart b/lib/core/backup/backup_file_import_port.dart new file mode 100644 index 00000000..5c10c21c --- /dev/null +++ b/lib/core/backup/backup_file_import_port.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/ports/backup_file_import_port.dart'; +import 'backup_limits.dart'; + +class NativeBackupFileImportPort implements BackupFileImportPort { + const NativeBackupFileImportPort(); + static const channel = MethodChannel('ontime/backup_import'); + + @override + Future select({BackupProcessingLease? lease}) async { + final handle = await _native('begin'); + if (handle == null || handle.isEmpty) _io(); + final source = _NativeImportSource(handle, lease); + lease?.cancellationRequested.then((_) async { + // Retain the same close flight; openRead/select will observe its result. + try { + await source.close(); + } catch (_) { + /* owner retries actual cleanup */ + } + }); + try { + lease?.check(); + final reply = await _native>('pick', { + 'handle': handle, + }); + lease?.check(); + if (reply == null) { + await source.close(); + return null; + } + final size = reply['length']; + if (size != null && (size is! int || size < 0)) _io(); + source._declaredLength = size as int?; + if (size is int && size > BackupLimits.cipherBytes) { + BackupLimits.exceeded('cipherBytes'); + } + return source; + } catch (original) { + try { + await source.close(); + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } +} + +Future _native(String method, [Map? arguments]) async { + try { + return await NativeBackupFileImportPort.channel.invokeMethod( + method, + arguments, + ); + } on PlatformException catch (e) { + throw BackupProcessingFailure(switch (e.code) { + 'import_limit' => BackupFailureKind.resourceLimit, + 'import_cancelled' => BackupFailureKind.userCancelled, + _ => BackupFailureKind.inputOutput, + }, e.code == 'import_limit' ? 'cipherBytes' : null); + } +} + +Never _io() => + throw const BackupProcessingFailure(BackupFailureKind.inputOutput); + +class _NativeImportSource implements BackupImportSource { + _NativeImportSource(this.handle, this.lease); + final String handle; + final BackupProcessingLease? lease; + int? _declaredLength; + bool _opened = false; + bool _closed = false; + bool _cleanupRetained = false; + Future? _closing; + @override + int? get declaredLength => _declaredLength; + + @override + Stream openRead() async* { + if (_opened || _closed) _io(); + _opened = true; + var consumed = 0; + Object? original; + try { + while (true) { + lease?.check(); + // One-byte EOF probe at the cap; no cap+1 allocation. + final request = (BackupLimits.cipherBytes - consumed + 1).clamp( + 1, + 65536, + ); + final reply = await _native>('read', { + 'handle': handle, + 'maxBytes': request, + }); + lease?.check(); + if (reply == null || + reply['bytes'] is! Uint8List || + reply['eof'] is! bool) { + _io(); + } + final bytes = reply['bytes'] as Uint8List; + final eof = reply['eof'] as bool; + if (bytes.length > request) _io(); + if (bytes.length > BackupLimits.cipherBytes - consumed) { + BackupLimits.exceeded('cipherBytes'); + } + consumed += bytes.length; + if (eof) { + if (bytes.isNotEmpty) _io(); + break; + } + if (bytes.isEmpty) _io(); + yield bytes; + } + } catch (error) { + original = error; + rethrow; + } finally { + try { + await close(); + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + } + } + + @override + Future close() => + _closing ??= _close().whenComplete(() => _closing = null); + Future _close() async { + if (_closed) return; + try { + final acknowledged = await _native('close', {'handle': handle}); + if (acknowledged != true) _io(); + _closed = true; + } catch (_) { + if (!_cleanupRetained && lease != null) { + _cleanupRetained = true; + lease!.retainCleanup(close); + } + rethrow; + } + } +} diff --git a/lib/core/backup/backup_ingestion_store.dart b/lib/core/backup/backup_ingestion_store.dart new file mode 100644 index 00000000..ac97fedc --- /dev/null +++ b/lib/core/backup/backup_ingestion_store.dart @@ -0,0 +1,842 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:path/path.dart' as p; +import 'package:sqlite3/sqlite3.dart' as sql; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/database/local_data_files.dart'; +import 'package:on_time_front/core/database/sqlcipher_loader.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'backup_private_files.dart'; +import 'backup_json_reader.dart'; +import 'backup_limits.dart'; +import 'restore_staging.dart'; + +/// Scalar replacement authorized by the authenticated time-review owner. +/// The expected value and existence bind it to the exact candidate revision. +class BackupTimeScalarEdit { + const BackupTimeScalarEdit({ + required this.parent, + required this.field, + required this.expectedExists, + required this.expectedValue, + required this.value, + }); + final int parent; + final String field; + final bool expectedExists; + final Object? expectedValue; + final Object value; +} + +/// A core-produced finite recurrence patch, not a client JSON editing API. +class BackupRecurringScalarEdit { + const BackupRecurringScalarEdit({ + required this.kind, + required this.identity, + required this.parent, + required this.field, + required this.expectedExists, + required this.expectedValue, + required this.value, + }); + final String kind; + final String identity; + final int parent; + final String field; + final bool expectedExists; + final Object? expectedValue; + final Object? value; +} + +/// Private provisional JSON graph, not an AppDatabase or portable schema. +/// Only an owned fresh file may be created. It has no activation authority. +class BackupIngestionStore implements BackupJsonSink { + BackupIngestionStore._(this._db, this._remove, this.budget) { + _db.execute('''CREATE TABLE nodes ( + id INTEGER PRIMARY KEY, parent INTEGER, key TEXT, kind TEXT NOT NULL, + value TEXT, UNIQUE(parent,key))'''); + _db.execute('CREATE INDEX node_parent_order ON nodes(parent,id)'); + _db.execute('''CREATE TABLE original_time_fields ( + parent INTEGER NOT NULL, field TEXT NOT NULL, existed INTEGER NOT NULL, + kind TEXT, value TEXT, PRIMARY KEY(parent,field))'''); + _db.execute('''CREATE TABLE records ( + kind TEXT NOT NULL, identity TEXT NOT NULL, node INTEGER NOT NULL, + owner TEXT, reference TEXT, slot TEXT, ordinal INTEGER, position INTEGER, + detail TEXT, PRIMARY KEY(kind,identity))'''); + _db.execute('CREATE INDEX record_owner ON records(kind,owner,position)'); + _db.execute('CREATE INDEX record_node ON records(kind,node)'); + _db.execute( + "CREATE UNIQUE INDEX occurrence_ordinal ON records(reference,ordinal) WHERE kind='occurrence'", + ); + _db.execute( + 'CREATE UNIQUE INDEX record_slot ON records(kind,reference,slot) WHERE slot IS NOT NULL', + ); + _db.execute('PRAGMA application_id=1330922057'); + _db.userVersion = 1; + _insert = _db.prepare( + 'INSERT INTO nodes(parent,key,kind,value) VALUES(?,?,?,?)', + ); + _db.execute('BEGIN'); + } + final sql.Database _db; + final Future Function() _remove; + final BackupBudget budget; + late final sql.PreparedStatement _insert; + int _pending = 0; + bool _writing = true; + bool _recordTransaction = false; + int _recordPending = 0; + bool _closed = false; + bool _statementClosed = false; + bool _released = false; + + /// Deliberately explicit plaintext in-memory test adapter; not cipher proof. + factory BackupIngestionStore.memoryForTesting( + BackupBudget budget, { + Future Function()? onRelease, + }) => BackupIngestionStore._( + sql.sqlite3.openInMemory(), + onRelease ?? () async {}, + budget, + ); + + static Future create( + BackupBudget budget, { + Directory? root, + String? attemptId, + }) async { + configureSqlCipherLoader(); + root ??= await RestoreStaging.directory(); + await root.create(recursive: true); + final id = attemptId ?? const Uuid().v4(); + if (!RegExp(r'^[a-zA-Z0-9-]{1,64}$').hasMatch(id)) { + throw ArgumentError('Invalid attempt identity'); + } + final file = File(p.join(root.path, '$id.ingestion.sqlite')); + // Reserve ownership before the first asynchronous file creation. A concurrent + // abandoned-file sweep must never mistake our pending creation for an orphan. + if (!BackupPrivateFiles.livePaths.add(file.path)) { + throw StateError('Attempt already owned'); + } + var created = false; + final key = Uint8List.fromList( + List.generate(32, (_) => Random.secure().nextInt(256)), + ); + sql.Database? raw; + Future remove() async { + if (!created) { + BackupPrivateFiles.livePaths.remove(file.path); + return; + } + for (final suffix in ['', '-wal', '-shm', '-journal']) { + final member = File('${file.path}$suffix'); + if (await member.exists()) await member.delete(); + if (await member.exists()) { + throw StateError('Encrypted ingestion cleanup unconfirmed'); + } + } + BackupPrivateFiles.livePaths.remove(file.path); + } + + try { + await file.create(exclusive: true); + created = true; + await excludeLocalDatabaseFromPlatformBackup(file); + await excludeLocalDatabaseFromPlatformBackup(File(root.path)); + raw = sql.sqlite3.open(file.path); + final capability = raw.select('PRAGMA cipher_version'); + if (capability.length != 1 || + capability.single.values.single is! String || + (capability.single.values.single as String).isEmpty) { + throw const EncryptedDatabaseUnavailable(); + } + final hex = key.map((v) => v.toRadixString(16).padLeft(2, '0')).join(); + raw.execute('PRAGMA key = "x\'$hex\'"'); + // This narrow fresh-file contract does not disable the active schema guard. + if (raw.userVersion != 0 || + raw + .select( + "SELECT name FROM sqlite_master WHERE name NOT GLOB 'sqlite_*'", + ) + .isNotEmpty) { + throw const EncryptedDatabaseUnavailable(); + } + raw.execute('PRAGMA cipher_memory_security=ON'); + raw.execute('PRAGMA temp_store=MEMORY'); + final store = BackupIngestionStore._(raw, remove, budget); + // Force actual encrypted/keyed reads before accepting provisional content. + raw.select('SELECT count(*) FROM nodes'); + return store; + } catch (original) { + Future cleanupOwned() async { + if (raw != null) { + raw!.dispose(); + raw = null; + } + await remove(); + } + + try { + await cleanupOwned(); + } catch (cleanup) { + budget.lease?.retainCleanup(cleanupOwned); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } finally { + key.fillRange(0, key.length, 0); + } + } + + @override + int writeNode(int? parent, String? key, String kind, Object? value) { + if (!_writing || _closed) throw StateError('Ingestion writer closed'); + try { + _insert.execute([ + parent, + key, + kind, + value == null + ? null + : value is String + ? value + : jsonEncode(value), + ]); + } on sql.SqliteException catch (error) { + if (error.resultCode == 19) BackupLimits.invalid(); + rethrow; + } + final id = _db.lastInsertRowId; + if (++_pending == 256) { + _db.execute('COMMIT'); + _db.execute('BEGIN'); + _pending = 0; + } + return id; + } + + void finish() { + if (_writing) { + _db.execute('COMMIT'); + _writing = false; + } + if (_db.select('PRAGMA quick_check').single.values.single != 'ok') { + BackupLimits.invalid(); + } + } + + Map node(int id) { + budget.visit(); + final rows = _db.select('SELECT * FROM nodes WHERE id=?', [id]); + if (rows.length != 1) BackupLimits.invalid(); + return rows.single; + } + + int? child(int parent, String key) { + budget.visit(); + final rows = _db.select('SELECT id FROM nodes WHERE parent=? AND key=?', [ + parent, + key, + ]); + return rows.isEmpty ? null : rows.single['id'] as int; + } + + Iterable> children(int parent) sync* { + var after = 0; + while (true) { + final batch = _db.select( + 'SELECT id,parent,key,kind FROM nodes WHERE parent=? AND id>? ORDER BY id LIMIT 128', + [parent, after], + ); + if (batch.isEmpty) return; + for (final row in batch) { + budget.visit(); + after = row['id'] as int; + yield row; + } + } + } + + Object? scalar(int id) { + final row = node(id); + if (row['kind'] == 'object' || row['kind'] == 'array') { + BackupLimits.invalid(); + } + return row['kind'] == 'string' + ? row['value'] + : row['value'] == null + ? null + : jsonDecode(row['value'] as String); + } + + /// Only explicitly requested fields are decoded. Unknown subtrees have been + /// bounded and duplicate-checked during parsing, but never reassembled here. + Map fields(int id, Set fields) { + if (node(id)['kind'] != 'object') BackupLimits.invalid(); + if (fields.isEmpty) return {}; + // The schema supplies this fixed small field list. A wide unknown object + // does not become a wide result or an in-memory key set here. + final rows = _db.select( + 'SELECT key,kind,value FROM nodes WHERE parent=? AND key IN (${List.filled(fields.length, '?').join(',')})', + [id, ...fields], + ); + budget.visit(rows.length); + final result = {}; + for (final row in rows) { + if (row['kind'] == 'array' || row['kind'] == 'object') { + BackupLimits.invalid(); + } + result[row['key'] as String] = row['kind'] == 'string' + ? row['value'] + : row['value'] == null + ? null + : jsonDecode(row['value'] as String); + } + return result; + } + + int requiredChild(int parent, String key, String kind) { + final id = child(parent, key); + if (id == null || node(id)['kind'] != kind) BackupLimits.invalid(); + return id; + } + + /// Disk-indexed identities and relations. Duplicate identities never replace + /// earlier records, even if the apparent payloads happen to be equal. + void addRecord( + String kind, + String identity, + int node, { + String? owner, + String? reference, + String? slot, + int? ordinal, + int? position, + String? detail, + }) { + budget.visit(); + try { + _db.execute( + 'INSERT INTO records(kind,identity,node,owner,reference,slot,ordinal,position,detail) VALUES(?,?,?,?,?,?,?,?,?)', + [ + kind, + identity, + node, + owner, + reference, + slot, + ordinal, + position, + detail, + ], + ); + } on sql.SqliteException catch (error) { + if (error.resultCode == 19) BackupLimits.invalid(); + rethrow; + } + if (_recordTransaction && ++_recordPending == 256) { + _db.execute('COMMIT'); + _recordTransaction = false; + _db.execute('BEGIN'); + _recordTransaction = true; + _recordPending = 0; + } + } + + Future validateRecords(Future Function() validate) async { + if (_writing || _closed || _recordTransaction) { + throw StateError('Invalid validation phase'); + } + _db.execute('BEGIN'); + _recordTransaction = true; + _recordPending = 0; + try { + await validate(); + _db.execute('COMMIT'); + _recordTransaction = false; + } catch (original) { + // A rollback failure leaves ownership live; release retries it and never + // grants preview authority to this provisional database. + try { + if (_recordTransaction) { + _db.execute('ROLLBACK'); + _recordTransaction = false; + } + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + /// A fresh validator must rebuild all derived indexes; successful earlier + /// validation batches may already have committed before a later failure. + void resetDerivedRecords() { + if (_writing || _closed || _recordTransaction) { + throw StateError('Invalid revalidation phase'); + } + final count = + _db.select('SELECT count(*) AS n FROM records').single['n'] as int; + budget.visit(count + 1); + _db.execute('DELETE FROM records'); + } + + /// Preserve original literals inside the same encrypted owner. This method + /// never edits identities, relation edges, recurrence ordinals or arbitrary + /// JSON subtrees. A separate bounded recurrence plan owns those operations. + void editTimeScalars(List edits) { + if (_writing || + _closed || + _recordTransaction || + edits.isEmpty || + edits.length > 4) { + throw StateError('Invalid time edit phase'); + } + const allowed = { + 'civilTime', + 'timeZoneId', + 'occurrenceOffsetSeconds', + 'recurringOverrides', + 'startedAt', + 'finishedAt', + 'createdAt', + 'updatedAt', + }; + final keys = {}; + for (final edit in edits) { + budget.visit(); + if (!allowed.contains(edit.field) || + !keys.add('${edit.parent}:${edit.field}') || + (edit.value is! String && edit.value is! int)) { + BackupLimits.invalid(); + } + if (edit.value is String) BackupLimits.string(edit.value as String); + if (node(edit.parent)['kind'] != 'object') BackupLimits.invalid(); + final existing = child(edit.parent, edit.field); + if ((existing != null) != edit.expectedExists || + (existing != null && scalar(existing) != edit.expectedValue)) { + throw StateError('Candidate field changed'); + } + final previousBytes = existing == null + ? 0 + : utf8.encode(jsonEncode(edit.expectedValue)).length; + final nextBytes = + utf8.encode(jsonEncode(edit.value)).length + + (existing == null + ? utf8.encode(jsonEncode(edit.field)).length + 2 + : 0); + if (nextBytes > previousBytes) { + budget.plaintext(nextBytes - previousBytes); + } + } + _db.execute('BEGIN'); + _recordTransaction = true; + try { + for (final edit in edits) { + final existing = _db.select( + 'SELECT kind,value FROM nodes WHERE parent=? AND key=?', + [edit.parent, edit.field], + ); + _db.execute( + 'INSERT OR IGNORE INTO original_time_fields(parent,field,existed,kind,value) VALUES(?,?,?,?,?)', + [ + edit.parent, + edit.field, + existing.isEmpty ? 0 : 1, + existing.isEmpty ? null : existing.single['kind'], + existing.isEmpty ? null : existing.single['value'], + ], + ); + final kind = edit.value is String ? 'string' : 'number'; + final value = edit.value is String + ? edit.value + : jsonEncode(edit.value); + if (existing.isEmpty) { + _db.execute( + 'INSERT INTO nodes(parent,key,kind,value) VALUES(?,?,?,?)', + [edit.parent, edit.field, kind, value], + ); + } else { + _db.execute( + 'UPDATE nodes SET kind=?,value=? WHERE parent=? AND key=?', + [kind, value, edit.parent, edit.field], + ); + } + } + _db.execute('COMMIT'); + _recordTransaction = false; + } catch (original) { + try { + _db.execute('ROLLBACK'); + _recordTransaction = false; + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + /// Atomically closes one source segment, moves a finite set of schedules, + /// and appends its new segment/exclusions. Original scalars stay encrypted. + /// Derived records deliberately remain stale until the owner runs a fresh + /// whole-candidate validation pass; this method grants no ready authority. + void editRecurringTimePlan({ + required int segmentArray, + required int exclusionArray, + required List edits, + required Map newSegment, + required List> newExclusions, + }) { + if (_writing || + _closed || + _recordTransaction || + edits.isEmpty || + edits.length > BackupLimits.schedules * 7 + 1 || + newExclusions.length > BackupLimits.records) { + BackupLimits.invalid(); + } + if (node(segmentArray)['kind'] != 'array' || + node(exclusionArray)['kind'] != 'array') { + BackupLimits.invalid(); + } + const scheduleFields = { + 'civilTime', + 'timeZoneId', + 'occurrenceOffsetSeconds', + 'recurringSegmentId', + 'recurringSlotKey', + 'recurringOrdinal', + 'recurringOverrides', + }; + final seen = {}; + var closed = 0; + for (final edit in edits) { + budget.visit(); + if (!seen.add('${edit.parent}:${edit.field}') || + record(edit.kind, edit.identity)?['node'] != edit.parent || + !(edit.kind == 'segment' && edit.field == 'beforeSlot' || + edit.kind == 'schedule' && scheduleFields.contains(edit.field))) { + BackupLimits.invalid(); + } + if (edit.kind == 'segment') closed++; + final existing = child(edit.parent, edit.field); + if ((existing != null) != edit.expectedExists || + (existing != null && scalar(existing) != edit.expectedValue)) { + throw StateError('Candidate field changed'); + } + final next = edit.value; + if (next != null && next is! String && next is! int) { + BackupLimits.invalid(); + } + if (next is String) BackupLimits.string(next); + final growth = + utf8.encode(jsonEncode(next)).length - + (existing == null + ? 0 + : utf8.encode(jsonEncode(edit.expectedValue)).length) + + (existing == null + ? utf8.encode(jsonEncode(edit.field)).length + 2 + : 0); + if (growth > 0) budget.plaintext(growth); + } + if (closed != 1 || record('segment', newSegment['id'] as String) != null) { + BackupLimits.invalid(); + } + const segmentFields = { + 'id', + 'seriesId', + 'preparationId', + 'fromSlot', + 'beforeSlot', + 'createdAt', + 'preparationNotBefore', + 'ruleJson', + 'scheduleJson', + }; + if (newSegment.keys.toSet().difference(segmentFields).isNotEmpty || + !newSegment.keys.toSet().containsAll(segmentFields)) { + BackupLimits.invalid(); + } + for (final object in [newSegment, ...newExclusions]) { + budget.admit(); + budget.visit(object.length + 1); + if (!identical(object, newSegment) && + (object.length != 3 || + !object.keys.toSet().containsAll({ + 'segmentId', + 'slotKey', + 'ordinal', + }) || + object['segmentId'] != newSegment['id'])) { + BackupLimits.invalid(); + } + for (final value in object.values) { + if (value != null && value is! String && value is! int) { + BackupLimits.invalid(); + } + if (value is String) BackupLimits.string(value); + } + budget.plaintext(utf8.encode(jsonEncode(object)).length + 1); + } + String kind(Object? value) => value == null + ? 'null' + : value is String + ? 'string' + : 'number'; + Object? encoded(Object? value) => value == null + ? null + : value is String + ? value + : jsonEncode(value); + void append(int parent, Map object) { + final key = + (_db.select('SELECT count(*) AS n FROM nodes WHERE parent=?', [ + parent, + ]).single['n'] + as int) + .toString(); + _db.execute('INSERT INTO nodes(parent,key,kind) VALUES(?,?,?)', [ + parent, + key, + 'object', + ]); + final id = _db.lastInsertRowId; + for (final entry in object.entries) { + _db.execute( + 'INSERT INTO nodes(parent,key,kind,value) VALUES(?,?,?,?)', + [id, entry.key, kind(entry.value), encoded(entry.value)], + ); + } + } + + _db.execute('BEGIN'); + _recordTransaction = true; + try { + for (final edit in edits) { + final existing = _db.select( + 'SELECT kind,value FROM nodes WHERE parent=? AND key=?', + [edit.parent, edit.field], + ); + _db.execute( + 'INSERT OR IGNORE INTO original_time_fields(parent,field,existed,kind,value) VALUES(?,?,?,?,?)', + [ + edit.parent, + edit.field, + existing.isEmpty ? 0 : 1, + existing.isEmpty ? null : existing.single['kind'], + existing.isEmpty ? null : existing.single['value'], + ], + ); + if (existing.isEmpty) { + _db.execute( + 'INSERT INTO nodes(parent,key,kind,value) VALUES(?,?,?,?)', + [edit.parent, edit.field, kind(edit.value), encoded(edit.value)], + ); + } else { + _db.execute( + 'UPDATE nodes SET kind=?,value=? WHERE parent=? AND key=?', + [kind(edit.value), encoded(edit.value), edit.parent, edit.field], + ); + } + } + append(segmentArray, newSegment); + for (final exclusion in newExclusions) { + append(exclusionArray, exclusion); + } + _db.execute('COMMIT'); + _recordTransaction = false; + } catch (original) { + try { + _db.execute('ROLLBACK'); + _recordTransaction = false; + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + Object? originalTimeScalar(int parent, String field) { + budget.visit(); + final original = _db.select( + 'SELECT existed,kind,value FROM original_time_fields WHERE parent=? AND field=?', + [parent, field], + ); + if (original.isEmpty) { + final id = child(parent, field); + return id == null ? null : scalar(id); + } + final row = original.single; + return row['existed'] == 0 || row['value'] == null + ? null + : row['kind'] == 'string' + ? row['value'] + : jsonDecode(row['value'] as String); + } + + String fieldPath(int parent, String field) { + final parts = [field]; + int? current = parent; + while (current != null) { + final value = node(current); + final owner = value['parent'] as int?; + if (owner == null) break; + final key = value['key'] as String; + parts.add(node(owner)['kind'] == 'array' ? '[$key]' : key); + current = owner; + if (parts.length > BackupLimits.depth + 1) BackupLimits.invalid(); + } + return parts.reversed.join('.').replaceAll('.[', '['); + } + + List> timeIssuePage(int after) => _db.select( + "SELECT * FROM records WHERE kind='timeIssue' AND node>? ORDER BY node LIMIT 21", + [after], + ); + + /// Explicit UI pagination is separately bounded per user request. It does + /// not re-run validation or spend a file's validation-work allowance. + List> impactPage(int after) => _db.select( + "SELECT r.node,r.detail,(SELECT value FROM nodes WHERE parent=r.node AND key='name') AS name,(SELECT value FROM nodes WHERE parent=r.node AND key='civilTime') AS civil,(SELECT value FROM nodes WHERE parent=r.node AND key='timeZoneId') AS zone,(SELECT value FROM nodes WHERE parent=r.node AND key='occurrenceOffsetSeconds') AS offset FROM records r WHERE r.kind='timezoneChange' AND r.node>? ORDER BY r.node LIMIT 21", + [after], + ); + + int recordCount(String kind) { + final count = + _db.select('SELECT count(*) AS n FROM records WHERE kind=?', [ + kind, + ]).single['n'] + as int; + budget.visit(count); + return count; + } + + Map? record(String kind, String identity) { + budget.visit(); + final rows = _db.select( + 'SELECT * FROM records WHERE kind=? AND identity=?', + [kind, identity], + ); + return rows.isEmpty ? null : rows.single; + } + + Iterable> occurrences(String segment) sync* { + var after = ''; + while (true) { + final batch = _db.select( + "SELECT * FROM records WHERE kind='occurrence' AND reference=? AND slot>? ORDER BY slot LIMIT 64", + [segment, after], + ); + if (batch.isEmpty) return; + for (final row in batch) { + budget.visit(); + after = row['slot'] as String; + yield row; + } + } + } + + Map? lastOccurrence(String segment) { + budget.visit(); + final rows = _db.select( + "SELECT * FROM records WHERE kind='occurrence' AND reference=? ORDER BY slot DESC LIMIT 1", + [segment], + ); + return rows.isEmpty ? null : rows.single; + } + + Map? recordAtSlot( + String kind, + String reference, + String slot, + ) { + budget.visit(); + final rows = _db.select( + 'SELECT * FROM records WHERE kind=? AND reference=? AND slot=?', + [kind, reference, slot], + ); + return rows.isEmpty ? null : rows.single; + } + + Iterable> records(String kind, {String? owner}) sync* { + var after = 0; + while (true) { + final batch = _db.select( + owner == null + ? 'SELECT * FROM records WHERE kind=? AND node>? ORDER BY node LIMIT 64' + : 'SELECT * FROM records WHERE kind=? AND owner=? AND node>? ORDER BY node LIMIT 64', + owner == null ? [kind, after] : [kind, owner, after], + ); + if (batch.isEmpty) return; + for (final row in batch) { + budget.visit(); + after = row['node'] as int; + yield row; + } + } + } + + Future release() async { + if (_released) return; + if (!_closed) { + Object? first; + try { + if (_writing || _recordTransaction) { + _db.execute('ROLLBACK'); + _writing = false; + _recordTransaction = false; + } + } catch (error) { + first = error; + } + try { + if (!_statementClosed) { + _insert.dispose(); + _statementClosed = true; + } + } catch (error) { + first ??= error; + } + try { + _db.dispose(); + _closed = true; + } catch (error) { + throw BackupProcessingCleanupFailure( + originalError: first, + cleanupError: error, + ); + } + // A confirmed DB close also finalizes its statements. File deletion can be + // retried independently, but a failed DB close never authorizes deletion. + if (first != null) { + try { + await _remove(); + _released = true; + } catch (cleanup) { + throw BackupProcessingCleanupFailure( + originalError: first, + cleanupError: cleanup, + ); + } + throw first; + } + } + await _remove(); + _released = true; + } +} diff --git a/lib/core/backup/backup_json_reader.dart b/lib/core/backup/backup_json_reader.dart new file mode 100644 index 00000000..5965e9f9 --- /dev/null +++ b/lib/core/backup/backup_json_reader.dart @@ -0,0 +1,381 @@ +import 'dart:convert'; +import 'backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +/// The production sink uses a private encrypted node table with a unique +/// (parent,key) index. It does not accumulate object keys or a whole JSON map. +abstract interface class BackupJsonSink { + int writeNode(int? parent, String? key, String kind, Object? value); +} + +/// Incremental strict JSON reader. It holds at most one bounded scalar token +/// and 32 structural frames; a sink owns original order and duplicate checks. +class BackupJsonReader { + BackupJsonReader(this.sink, this.budget, {this.portableRecords = false}) { + if (portableRecords) budget.earlyRecordAdmission = true; + } + final bool portableRecords; + final BackupJsonSink sink; + final BackupBudget budget; + final _frames = <_Frame>[]; + int? root; + String _mode = ''; + var _token = StringBuffer(); + int _stringBytes = 0; + int _stringLimit = BackupLimits.stringBytes; + int? _highSurrogate; + bool _escaped = false; + int _unicodeDigits = 0; + int _unicodeValue = 0; + bool _rootComplete = false; + + Future read(Stream> utf8Bytes) async { + try { + return await _read(utf8Bytes); + } on FormatException catch (error) { + if (error is BackupProcessingFailure) rethrow; + BackupLimits.invalid(); + } + } + + Future _read(Stream> utf8Bytes) async { + var unitsSinceYield = 0; + await for (final chunk in utf8.decoder.bind(utf8Bytes)) { + budget.lease?.check(); + for (final code in chunk.codeUnits) { + _consume(code); + } + // Writers may emit punctuation-sized fragments. Yield by actual decoded + // work, not per fragment, while cancellation is checked on every fragment. + unitsSinceYield += chunk.length; + if (unitsSinceYield >= 16384) { + unitsSinceYield = 0; + await Future.delayed(Duration.zero); + } + } + if (_mode == 'literal') _finishLiteral(); + if (_mode.isNotEmpty || + _frames.isNotEmpty || + !_rootComplete || + root == null) { + BackupLimits.invalid(); + } + return root!; + } + + bool get _expectsKey => + _frames.isNotEmpty && + _frames.last.kind == 'object' && + (_frames.last.state == 'keyOrEnd' || _frames.last.state == 'key'); + + void _consume(int c) { + if (_mode == 'string') { + _string(c); + return; + } + if (_mode == 'literal') { + if (_delimiter(c)) { + _finishLiteral(); + _consume(c); + } else { + if (_token.length == BackupLimits.stringBytes) { + BackupLimits.exceeded('token'); + } + _token.writeCharCode(c); + } + return; + } + if (_whitespace(c)) return; + if (c == 34) { + if (!_expectsKey) _requireValue(); + _mode = 'string'; + _token = StringBuffer(); + _stringBytes = 0; + final identifier = + _expectsKey || + (portableRecords && + _frames.isNotEmpty && + {'schedule', 'template', 'record'}.contains(_frames.last.role) && + { + 'id', + 'nextId', + 'timeZoneId', + 'preparationTemplateId', + 'recurringSegmentId', + 'preparationDefinitionId', + 'ownerId', + 'definitionId', + 'seriesId', + 'preparationId', + 'segmentId', + }.contains(_frames.last.key)); + _stringLimit = identifier + ? BackupLimits.identifierBytes + : BackupLimits.stringBytes; + return; + } + switch (c) { + case 123: + _open('object'); + case 91: + _open('array'); + case 125: + _close('object'); + case 93: + _close('array'); + case 58: + if (_frames.isEmpty || _frames.last.state != 'colon') { + BackupLimits.invalid(); + } + _frames.last.state = 'value'; + case 44: + if (_frames.isEmpty || _frames.last.state != 'commaOrEnd') { + BackupLimits.invalid(); + } + final frame = _frames.last; + frame.state = frame.kind == 'object' ? 'key' : 'value'; + default: + _requireValue(); + if (!(c == 45 || + (c >= 48 && c <= 57) || + c == 116 || + c == 102 || + c == 110)) { + BackupLimits.invalid(); + } + _mode = 'literal'; + _token = StringBuffer()..writeCharCode(c); + } + } + + static bool _whitespace(int c) => c == 32 || c == 9 || c == 10 || c == 13; + static bool _delimiter(int c) => + _whitespace(c) || c == 44 || c == 93 || c == 125; + + void _requireValue() { + if (_frames.isEmpty) { + if (_rootComplete || root != null) BackupLimits.invalid(); + } else if (!{'value', 'valueOrEnd'}.contains(_frames.last.state)) { + BackupLimits.invalid(); + } + } + + int _node(String kind, Object? value) { + _requireValue(); + budget.visit(); + final parent = _frames.isEmpty ? null : _frames.last; + final key = parent == null + ? null + : parent.kind == 'array' + ? '${parent.index++}' + : parent.key; + if (portableRecords && parent != null) { + final record = + { + 'schedules', + 'defaultPreparation', + 'preparationList', + 'templates', + 'recurringRecords', + }.contains(parent.role) || + (parent.role == 'root' && {'profile', 'preferences'}.contains(key)) || + (parent.role == 'schedule' && key == 'place'); + if (record) { + if ({'defaultPreparation', 'preparationList'}.contains(parent.role) && + parent.index > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + budget.admit(schedule: parent.role == 'schedules'); + } + } + final id = sink.writeNode(parent?.id, key, kind, value); + if (parent == null) { + root = id; + _rootComplete = true; + } else { + parent.state = 'commaOrEnd'; + parent.key = null; + } + return id; + } + + String _valueRole() { + if (_frames.isEmpty) return 'root'; + final parent = _frames.last; + if (parent.role == 'root' && + { + 'schedules', + 'defaultPreparation', + 'schedulePreparations', + 'templates', + 'recurring', + }.contains(parent.key)) { + return parent.key!; + } + if (parent.role == 'schedules') return 'schedule'; + if ({ + 'defaultPreparation', + 'preparationList', + 'recurringRecords', + }.contains(parent.role) || + (parent.role == 'schedule' && parent.key == 'place')) { + return 'record'; + } + if (parent.role == 'templates') return 'template'; + if (parent.role == 'schedulePreparations' || + (parent.role == 'template' && parent.key == 'preparation')) { + return 'preparationList'; + } + if (parent.role == 'recurring' && + { + 'definitions', + 'steps', + 'segments', + 'exclusions', + }.contains(parent.key)) { + return 'recurringRecords'; + } + return ''; + } + + void _open(String kind) { + if (_frames.length == BackupLimits.depth) BackupLimits.exceeded('depth'); + final role = _valueRole(); + final id = _node(kind, null); + _frames.add(_Frame(id, kind, role)); + } + + void _close(String kind) { + if (_frames.isEmpty || _frames.last.kind != kind) BackupLimits.invalid(); + final state = _frames.last.state; + if (state != 'commaOrEnd' && + state != (kind == 'object' ? 'keyOrEnd' : 'valueOrEnd')) { + BackupLimits.invalid(); + } + _frames.removeLast(); + } + + void _string(int c) { + if (_unicodeDigits > 0) { + final digit = c >= 48 && c <= 57 + ? c - 48 + : c >= 65 && c <= 70 + ? c - 55 + : c >= 97 && c <= 102 + ? c - 87 + : -1; + if (digit < 0) BackupLimits.invalid(); + _unicodeValue = _unicodeValue * 16 + digit; + if (--_unicodeDigits == 0) _appendScalarUnit(_unicodeValue); + return; + } + if (_escaped) { + _escaped = false; + if (c == 117) { + _unicodeDigits = 4; + _unicodeValue = 0; + return; + } + final decoded = switch (c) { + 34 => 34, + 92 => 92, + 47 => 47, + 98 => 8, + 102 => 12, + 110 => 10, + 114 => 13, + 116 => 9, + _ => -1, + }; + if (decoded < 0) BackupLimits.invalid(); + _appendScalarUnit(decoded); + return; + } + if (c == 92) { + _escaped = true; + return; + } + if (c == 34) { + if (_highSurrogate != null) BackupLimits.invalid(); + final value = _token.toString(); + _token = StringBuffer(); + _mode = ''; + if (_expectsKey) { + _frames.last.key = value; + _frames.last.state = 'colon'; + } else { + _node('string', value); + } + return; + } + if (c < 32) BackupLimits.invalid(); + _appendScalarUnit(c); + } + + void _appendScalarUnit(int c) { + if (_highSurrogate != null) { + if (c < 0xdc00 || c > 0xdfff) BackupLimits.invalid(); + _chargeString(4); + _token.writeCharCode(_highSurrogate!); + _token.writeCharCode(c); + _highSurrogate = null; + return; + } + if (c >= 0xd800 && c <= 0xdbff) { + _highSurrogate = c; + return; + } + if (c >= 0xdc00 && c <= 0xdfff) BackupLimits.invalid(); + _chargeString( + c < 128 + ? 1 + : c < 2048 + ? 2 + : 3, + ); + _token.writeCharCode(c); + } + + void _chargeString(int amount) { + if (amount > _stringLimit - _stringBytes) { + BackupLimits.exceeded(_expectsKey ? 'key' : 'string'); + } + _stringBytes += amount; + budget.buffer(_stringBytes); + } + + void _finishLiteral() { + final raw = _token.toString(); + _token = StringBuffer(); + _mode = ''; + final Object? value; + try { + value = jsonDecode(raw); + } catch (_) { + BackupLimits.invalid(); + } + if (value is double && !value.isFinite) BackupLimits.invalid(); + if (value != null && value is! num && value is! bool) { + BackupLimits.invalid(); + } + _node( + value == null + ? 'null' + : value is bool + ? 'bool' + : 'number', + value, + ); + } +} + +class _Frame { + _Frame(this.id, this.kind, this.role) + : state = kind == 'object' ? 'keyOrEnd' : 'valueOrEnd'; + final int id; + final String kind; + final String role; + String state; + String? key; + int index = 0; +} diff --git a/lib/core/backup/backup_limits.dart b/lib/core/backup/backup_limits.dart new file mode 100644 index 00000000..5d288ff6 --- /dev/null +++ b/lib/core/backup/backup_limits.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +abstract final class BackupLimits { + static const cipherBytes = 72 * 1024 * 1024; + static const plainBytes = 64 * 1024 * 1024; + static const depth = 32; + static const schedules = 100000; + static const records = 500000; + static const preparationSteps = 1000; + static const work = 2000000; + static const identifierBytes = 512; + static const stringBytes = 64 * 1024; + static const noteScalars = 16384; + static const exactInteger = 9007199254740991; + + static Never exceeded(String limit) => + throw BackupProcessingFailure(BackupFailureKind.resourceLimit, limit); + static Never invalid() => + throw const BackupProcessingFailure(BackupFailureKind.dataInvariant); + + static void string( + String value, { + bool identifier = false, + bool note = false, + }) { + final max = identifier ? identifierBytes : stringBytes; + // No larger UTF-8 encoding is allocated just to discover a known overrun. + if (value.length > max) exceeded(identifier ? 'identifier' : 'string'); + if (utf8.encode(value).length > max) { + exceeded(identifier ? 'identifier' : 'string'); + } + if (note && value.runes.length > noteScalars) exceeded('note'); + for (final rune in value.runes) { + if (rune >= 0xd800 && rune <= 0xdfff) invalid(); + } + } + + static int integer( + Object? value, { + int minimum = 0, + int maximum = exactInteger, + }) { + if (value is! int || value < minimum || value > maximum) invalid(); + return value; + } + + static int minutes(Object? value) { + // Dart Duration uses signed 64-bit microseconds on supported native targets. + return integer(value, maximum: 0x7fffffffffffffff ~/ 60000000); + } +} + +class BackupBudget { + BackupBudget({this.lease}) : _shared = _WorkMeter(); + BackupBudget._(this.lease, this._shared); + final _WorkMeter _shared; + + /// A distinct encoding pass has its own byte cap, sharing total logical work. + BackupBudget bytePass() => BackupBudget._(lease, _shared); + final BackupProcessingLease? lease; + int cipherBytes = 0; + int plainBytes = 0; + bool earlyRecordAdmission = false; + int records = 0; + int schedules = 0; + int get work => _shared.work; + + /// Largest individually accounted buffer/token, NOT simultaneous live bytes, + /// Dart heap, native allocation or process RSS. + int get largestTrackedBufferBytes => _shared.largestBuffer; + + void ciphertext(int amount) { + lease?.check(); + if (amount < 0 || amount > BackupLimits.cipherBytes - cipherBytes) { + BackupLimits.exceeded('cipherBytes'); + } + cipherBytes += amount; + } + + void plaintext(int amount) { + lease?.check(); + if (amount < 0 || amount > BackupLimits.plainBytes - plainBytes) { + BackupLimits.exceeded('plainBytes'); + } + plainBytes += amount; + } + + void visit([int amount = 1]) { + lease?.check(); + if (amount < 0 || amount > BackupLimits.work - work) { + BackupLimits.exceeded('work'); + } + _shared.work += amount; + } + + void admitValidated({bool schedule = false}) { + if (!earlyRecordAdmission) admit(schedule: schedule); + } + + void admit({bool schedule = false}) { + visit(); + if (records == BackupLimits.records) BackupLimits.exceeded('records'); + if (schedule && schedules == BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + records++; + if (schedule) schedules++; + } + + void buffer(int bytes) { + if (bytes > largestTrackedBufferBytes) _shared.largestBuffer = bytes; + } +} + +class _WorkMeter { + int work = 0; + int largestBuffer = 0; +} diff --git a/lib/core/backup/backup_private_files.dart b/lib/core/backup/backup_private_files.dart new file mode 100644 index 00000000..c88ebb97 --- /dev/null +++ b/lib/core/backup/backup_private_files.dart @@ -0,0 +1,5 @@ +/// Shared ownership of temporary encrypted backup files across normal restore, +/// pre-DI recovery and export. Cleanup must never delete another live attempt. +abstract final class BackupPrivateFiles { + static final livePaths = {}; +} diff --git a/lib/core/backup/backup_recurrence_scan.dart b/lib/core/backup/backup_recurrence_scan.dart new file mode 100644 index 00000000..371801a5 --- /dev/null +++ b/lib/core/backup/backup_recurrence_scan.dart @@ -0,0 +1,169 @@ +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'backup_limits.dart'; +import 'backup_value_validation.dart'; + +class BackupRuleCandidate { + const BackupRuleCandidate( + this.civil, + this.calendarOrdinal, + this.currentOrdinal, + this.offsets, + this.eligible, + ); + final DateTime civil; + final int calendarOrdinal; + final int currentOrdinal; + final List offsets; + final bool eligible; +} + +typedef BackupOffsetLookup = + List Function(DateTime civil, String zone, BackupBudget budget); + +/// One bounded traversal per segment. Calendar membership/upper bound is +/// independent of tzdb; current ordinal is explicitly only a current-rule +/// observation, never proof of a historic rule version. +Stream scanBackupRule({ + required RecurrenceRule rule, + required DateTime through, + required BackupBudget budget, + required Duration leadTime, + DateTime? preparationNotBefore, + BackupOffsetLookup offsetLookup = backupOffsets, +}) async* { + var calendar = 0; + var counted = 0; + for (final date in _dates(rule, through, budget)) { + final civil = DateTime.utc( + date.year, + date.month, + date.day, + rule.start.hour, + rule.start.minute, + rule.start.second, + rule.start.millisecond, + rule.start.microsecond, + ); + if (civil.isBefore(rule.start) || civil.isAfter(through)) continue; + calendar++; + if (calendar % 256 == 0) await Future.delayed(Duration.zero); + final offsets = offsetLookup(civil, rule.timeZoneId, budget); + final selected = + offsets.isEmpty || (offsets.length > 1 && rule.repeatedTime == null) + ? null + : rule.repeatedTime == RepeatedCivilTime.second + ? offsets.last + : offsets.first; + var eligible = selected != null; + if (selected != null && preparationNotBefore != null) { + final earliest = + civil.microsecondsSinceEpoch - + selected * 1000000 - + leadTime.inMicroseconds; + eligible = + earliest >= preparationNotBefore.toUtc().microsecondsSinceEpoch; + } + if (eligible) counted++; + yield BackupRuleCandidate( + civil, + calendar, + counted, + offsets, + eligible && (rule.count == null || counted <= rule.count!), + ); + } +} + +List backupOffsets(DateTime civil, String zone, BackupBudget budget) { + BackupValueValidation.namedZone(zone); + final location = tz.getLocation(zone); + final distinct = {}; + for (final zone in location.zones) { + budget.visit(); + distinct.add(zone.offset ~/ 1000); + } + final results = []; + for (final offset in distinct) { + budget.visit(); + final instant = civil.subtract(Duration(seconds: offset)); + final observed = tz.TZDateTime.from(instant, location); + if (observed.year == civil.year && + observed.month == civil.month && + observed.day == civil.day && + observed.hour == civil.hour && + observed.minute == civil.minute && + observed.second == civil.second && + observed.millisecond == civil.millisecond && + observed.microsecond == civil.microsecond) { + results.add(offset); + } + } + // Earliest instant has the largest UTC offset (e.g. -04 before -05). + results.sort((a, b) => b.compareTo(a)); + return results; +} + +Iterable _dates( + RecurrenceRule rule, + DateTime through, + BackupBudget budget, +) sync* { + var end = RecurrenceRule.civilDate(through); + if (rule.until != null && rule.until!.isBefore(end)) end = rule.until!; + final start = RecurrenceRule.civilDate(rule.start); + switch (rule.frequency) { + case RecurrenceFrequency.daily: + var date = start; + while (!date.isAfter(end)) { + budget.visit(); + yield date; + if (end.difference(date).inDays < rule.interval) break; + date = date.add(Duration(days: rule.interval)); + } + case RecurrenceFrequency.weekly: + var week = start.subtract(Duration(days: start.weekday - 1)); + final weekdays = rule.weekdays.toList()..sort(); + while (!week.isAfter(end)) { + budget.visit(); + for (final weekday in weekdays) { + budget.visit(); + final date = week.add(Duration(days: weekday - 1)); + if (!date.isBefore(start) && !date.isAfter(end)) yield date; + } + if (end.difference(week).inDays ~/ 7 < rule.interval) break; + week = week.add(Duration(days: 7 * rule.interval)); + } + case RecurrenceFrequency.monthly: + var month = start.year * 12 + start.month - 1; + final lastMonth = end.year * 12 + end.month - 1; + while (month <= lastMonth) { + budget.visit(); + final first = DateTime.utc(month ~/ 12, month % 12 + 1); + final last = DateTime.utc(first.year, first.month + 1, 0).day; + final int day; + switch (rule.monthly) { + case MonthlyRecurrence.dayOfMonth: + day = rule.monthDay; + case MonthlyRecurrence.lastDay: + day = last; + case MonthlyRecurrence.nthWeekday: + day = rule.ordinal == -1 + ? last - + (DateTime.utc(first.year, first.month, last).weekday - + rule.monthWeekday + + 7) % + 7 + : 1 + + (rule.monthWeekday - first.weekday + 7) % 7 + + 7 * (rule.ordinal - 1); + } + if (day <= last) { + final date = DateTime.utc(first.year, first.month, day); + if (!date.isBefore(start) && !date.isAfter(end)) yield date; + } + if (lastMonth - month < rule.interval) break; + month += rule.interval; + } + } +} diff --git a/lib/core/backup/backup_recurring_time_review.dart b/lib/core/backup/backup_recurring_time_review.dart new file mode 100644 index 00000000..97aca538 --- /dev/null +++ b/lib/core/backup/backup_recurring_time_review.dart @@ -0,0 +1,563 @@ +part of 'backup_validated_ingestion.dart'; + +extension _AuthenticatedRecurringReview on BackupAuthenticatedTimeReview { + void _checkRecurringDraft(BackupRecurringTimeDraft draft) => _checkDraft( + BackupTimeDraft( + identity: draft.identity, + revision: draft.revision, + rulesIdentity: draft.rulesIdentity, + issueId: draft.issueId, + civil: CivilDateTime.fromFields(draft.rule.start), + zone: draft.rule.timeZoneId, + ), + ); + + Future _readRecurrenceRule(String issueId) async { + _check(); + if (!_structureChecked) throw const BackupTimeReviewStale(); + final revision = _revision; + final row = _store.record('timeIssue', issueId); + if (row == null || row['reference'] != 'ruleJson') { + throw const BackupTimeReviewStale(); + } + final raw = _data._segment(int.parse(row['owner'] as String)); + final rule = (await _data._spec(raw)).rule; + _check(); + if (!_structureChecked || revision != _revision) { + throw const BackupTimeReviewStale(); + } + return rule; + } + + bool _protectedForPlan(ScheduleEntity value, DateTime now) => + RecurrenceReferencePolicy.hasProtectedFacts(value) || + value.startedAt != null || + value.finishedAt != null || + value.scoreContributionRecorded || + ScheduleTimeResolver.resolve(value, nowUtc: now).isHistorical || + RecurrenceReferencePolicy.isProvablyPast(value, now); + + Future _reviewRecurrence( + BackupRecurringTimeDraft draft, + ) async { + _checkRecurringDraft(draft); + final at = _now().toUtc(); + final issueRecord = _store.record('timeIssue', draft.issueId); + if (issueRecord == null || issueRecord['reference'] != 'ruleJson') { + throw const BackupTimeReviewStale(); + } + final issue = _issue(issueRecord); + final raw = _data._segment(int.parse(issueRecord['owner'] as String)); + final spec = await _data._spec(raw); + BackupValueValidation.namedZone(draft.rule.timeZoneId); + BackupLimits.string(jsonEncode(RecurrenceCodec.ruleToJson(draft.rule))); + BackupLimits.integer(draft.rule.interval, minimum: 1); + if (draft.rule.count != null) { + BackupLimits.integer(draft.rule.count, minimum: 1); + } + final from = CivilDateTime.parse(raw.fromSlot).toUtcCarrier(); + final before = raw.beforeSlot == null + ? null + : CivilDateTime.parse(raw.beforeSlot!).toUtcCarrier(); + final allRows = []; + for (final record in _store.records('schedule')) { + final value = _data._schedule(record['node'] as int); + if (value.recurringSegmentId == raw.id) allRows.add(value); + } + final futureRows = + allRows.where((row) => !_protectedForPlan(row, at)).toList() + ..sort((a, b) { + final cmp = CivilDateTime.parse( + a.recurringSlotKey!, + ).compareTo(CivilDateTime.parse(b.recurringSlotKey!)); + if (cmp != 0) return cmp; + final ordinal = a.recurringOrdinal!.compareTo(b.recurringOrdinal!); + return ordinal == 0 ? a.id.compareTo(b.id) : ordinal; + }); + final anchorRow = futureRows.firstOrNull; + final anchor = BackupRecurringTimeAnchor( + segmentId: raw.id, + originalSlot: anchorRow == null + ? from + : CivilDateTime.parse(anchorRow.recurringSlotKey!).toUtcCarrier(), + scheduleId: anchorRow?.id, + originalOrdinal: anchorRow?.recurringOrdinal, + ); + final unknown = !TimeZoneRules.contains(spec.rule.timeZoneId); + final prefixSafe = RecurrenceReferencePolicy.hasNoFutureGeneration( + rule: spec.rule, + fromSlot: from, + beforeSlot: anchor.originalSlot, + nowUtc: at, + ); + if (unknown && !prefixSafe && !draft.replaceWholeSourceInterval) { + throw const BackupRecurringWholeReplacementRequired(); + } + if (draft.replaceWholeSourceInterval && !unknown) { + throw const RecurrenceValidationException( + 'Whole source replacement is only available for an unresolved source zone.', + ); + } + final closeAt = draft.replaceWholeSourceInterval + ? from + : anchor.originalSlot; + if (before != null && !closeAt.isBefore(before)) { + throw const BackupTimeReviewStale(); + } + if (anchorRow == null && + (!draft.endExplicitlyChosen || + (draft.rule.count == null && draft.rule.until == null))) { + throw const RecurringTimeCorrectionCountRequired(); + } + final source = RecurringSegment( + id: raw.id, + seriesId: raw.seriesId, + rule: spec.rule, + schedule: spec.schedule, + preparation: _data._definitionPreparation(raw.preparationId), + preparationId: raw.preparationId, + fromSlot: from, + beforeSlot: before, + createdAt: raw.createdAt, + preparationNotBefore: raw.preparationNotBefore, + ); + final exclusions = []; + for (final value in _store.records('exclusion')) { + if (value['reference'] == raw.id) { + final row = _data._exclusion(value['node'] as int); + exclusions.add( + TimeCorrectionExclusion(row.segmentId, row.slotKey, row.ordinal), + ); + } + } + final occurrenceOwners = {}; + for (final value in _store.records('definition')) { + if (value['detail'] == 'occurrence') { + occurrenceOwners[value['identity'] as String] = + value['owner'] as String; + } + } + var mapping = await RecurringTimeCorrectionMapper.buildCandidate( + anchor: anchor, + closeAt: closeAt, + wholeSource: draft.replaceWholeSourceInterval, + segments: [source], + schedules: allRows, + exclusions: exclusions, + occurrenceDefinitionOwners: occurrenceOwners, + requested: draft.rule, + endExplicitlyChosen: draft.endExplicitlyChosen, + nowUtc: at, + budget: _store.budget, + ); + final unresolved = {}; + for (final item in mapping.rows) { + if (item.original.recurringOverrides.split(',').contains('time') || + item.detached) { + final resolved = ScheduleTimeResolver.resolve( + item.original, + nowUtc: at, + ); + if (resolved.instantUtc == null) { + unresolved[item.original.id] = resolved.status; + } + } + } + TimeCorrectionConflictProof proof; + if (unresolved.isEmpty) { + proof = await _recurrenceProof(source, mapping, closeAt, at); + if (draft.excludedConflictSlots.isNotEmpty) { + final eligible = { + for (final conflict in proof.conflicts.where( + (e) => !e.persistent, + )) ...[ + conflict.slot.key, + if (conflict.otherSlotKey != null) conflict.otherSlotKey!, + ], + }; + if (!eligible.containsAll(draft.excludedConflictSlots)) { + BackupLimits.invalid(); + } + final selected = proof.proposedSlots + .where((slot) => draft.excludedConflictSlots.contains(slot.key)) + .toList(); + if (selected.length != draft.excludedConflictSlots.length) { + BackupLimits.invalid(); + } + mapping = RecurringTimeCorrectionMapping( + rule: mapping.rule, + automaticallyRetainedCount: mapping.automaticallyRetainedCount, + rows: [ + for (final item in mapping.rows) + item.slot != null && + draft.excludedConflictSlots.contains(item.slot!.key) + ? TimeCorrectionRowMapping(item.original, null) + : item, + ], + protectedRows: mapping.protectedRows, + exclusions: mapping.exclusions, + protectedSlots: mapping.protectedSlots, + workUnits: _store.budget.work, + firstSlot: mapping.firstSlot, + conflictExclusions: selected, + ); + proof = await _recurrenceProof(source, mapping, closeAt, at); + } + } else { + if (draft.excludedConflictSlots.isNotEmpty) BackupLimits.invalid(); + // No claim of conflict safety: unresolved overrides block confirmation. + proof = TimeCorrectionConflictProof( + conflicts: [], + through: mapping.rule.start, + workUnits: _store.budget.work, + ); + } + _checkRecurringDraft(draft); + if (_now().toUtc().isBefore(at)) throw const BackupTimeReviewStale(); + final plan = BackupRecurringTimePlan( + draft: draft, + issue: issue, + anchor: anchor, + originalRule: spec.rule, + originalSchedule: spec.schedule, + originalFrom: from, + originalBefore: before, + closeAt: closeAt, + mapping: mapping, + conflicts: proof, + unresolvedOverrides: unresolved, + ); + if (_recurringIssued.length == 20) { + _recurringIssued.remove(_recurringIssued.keys.first); + } + _recurringIssued[plan] = at; + return plan; + } + + ScheduleEntity _mappedSchedule( + TimeCorrectionRowMapping change, + RecurrenceRule rule, + ) => + change.detached || + change.original.recurringOverrides.split(',').contains('time') + ? change.original + : change.original.copyWith( + scheduleTime: change.slot!.civilTime, + timeZoneId: rule.timeZoneId, + occurrenceOffsetSeconds: change.slot!.offsetSeconds, + ); + + Future _recurrenceProof( + RecurringSegment source, + RecurringTimeCorrectionMapping mapping, + DateTime closeAt, + DateTime now, + ) async { + final world = await _BackupConflictWorld.read( + _data, + now, + { + for (final item in mapping.rows) + if (!item.detached) item.original.id, + }, + closingSegments: {source.id: closeAt}, + ); + final overrides = { + for (final item in mapping.rows) + if (!item.detached) item.slot!.key: _mappedSchedule(item, mapping.rule), + }; + final proof = await TimeCorrectionConflictScanner.scan( + rule: mapping.rule, + base: source.schedule, + basePreparation: source.preparation.totalDuration, + proposedOverrides: overrides, + proposedPreparationById: { + for (final value in overrides.values) + value.id: _data._effectivePreparation(value).totalDuration, + }, + excludedSlots: { + for (final slot in mapping.protectedSlots) slot.key, + for (final slot in mapping.conflictExclusions) slot.key, + for (final item in mapping.exclusions) + if (item.slot != null) item.slot!.key, + }, + storedOthers: world.rows, + otherSegments: world.segments, + otherExcludedSlots: world.exclusions, + nowUtc: now, + budget: _store.budget, + ); + final start = proof.earliestPreparationUtc, end = proof.latestTargetUtc; + if (start == null || end == null) { + throw const RecurrenceValidationException( + 'The plan has no executable future occurrence.', + ); + } + return TimeCorrectionConflictProof( + conflicts: proof.conflicts, + through: proof.through, + workUnits: _store.budget.work, + proposedSlots: proof.proposedSlots, + earliestPreparationUtc: start, + latestTargetUtc: end, + possibleOverlaps: [ + for (final item in [...world.possible, ...proof.possibleOverlaps]) + if (item.mayOverlap(start, end)) item, + ], + ); + } + + Object _recurringSemantics(BackupRecurringTimePlan plan) { + Object? slot(RecurrenceSlot? value) => value == null + ? null + : [ + value.key, + value.ordinal, + value.offsetSeconds, + value.instantUtc.microsecondsSinceEpoch, + ]; + final map = plan.mapping; + return [ + plan.anchor.segmentId, + plan.anchor.scheduleId, + plan.anchor.originalSlot, + plan.anchor.originalOrdinal, + plan.closeAt, + RecurrenceCodec.ruleToJson(map.rule), + map.automaticallyRetainedCount, + slot(map.firstSlot), + [ + for (final row in map.rows) [row.original, slot(row.slot)], + ], + map.protectedRows, + [ + for (final value in map.exclusions) + [ + value.original.segmentId, + value.original.slot, + value.original.ordinal, + slot(value.slot), + ], + ], + [for (final value in map.protectedSlots) slot(value)], + [for (final value in map.conflictExclusions) slot(value)], + plan.unresolvedOverrides, + plan.conflicts.through, + plan.conflicts.earliestPreparationUtc, + plan.conflicts.latestTargetUtc, + [for (final value in plan.conflicts.proposedSlots) slot(value)], + [ + for (final value in plan.conflicts.possibleOverlaps) + [ + value.id, + value.reason, + value.originalCivil, + value.timeZoneId, + value.rule == null ? null : RecurrenceCodec.ruleToJson(value.rule!), + value.earliestPreparationUtc, + value.latestTargetUtc, + ], + ], + [ + for (final value in plan.conflicts.conflicts) + [ + slot(value.slot), + value.other.schedule, + value.other.preparationStartUtc, + value.other.instantUtc, + value.otherSlotKey, + value.persistent, + ], + ], + ]; + } + + Future _chooseRecurrence(BackupRecurringTimeChoice choice) async { + final old = choice.plan, draft = choice.plan.draft; + _checkRecurringDraft(draft); + final issued = _recurringIssued[old]; + final oldBoundary = old.conflicts.earliestPreparationUtc; + final chooseNow = _now().toUtc(); + if (issued == null || + chooseNow.isBefore(issued) || + oldBoundary == null || + !oldBoundary.isAfter(chooseNow)) { + throw const BackupTimeReviewStale(); + } + if (draft.replaceWholeSourceInterval && + !choice.confirmedWholeSourceReplacement) { + throw const BackupTimeReviewStale(); + } + if (!draft.replaceWholeSourceInterval && + choice.confirmedWholeSourceReplacement) { + throw const BackupTimeReviewStale(); + } + final plan = await _reviewRecurrence(draft); + _checkRecurringDraft(draft); + if (!const DeepCollectionEquality().equals( + _recurringSemantics(old), + _recurringSemantics(plan), + )) { + throw const BackupTimeReviewStale(); + } + if (plan.unresolvedOverrides.isNotEmpty || + plan.conflicts.earliestPreparationUtc == null) { + throw const BackupTimeReviewStale(); + } + if (plan.conflicts.conflicts.isNotEmpty) { + throw BackupTimeChoiceConflict(plan.conflicts); + } + final mapping = plan.mapping; + final detached = { + for (final item in mapping.rows) + if (item.detached) item.original.id, + }; + final unmatched = { + for (final item in mapping.exclusions) + if (item.slot == null) + '${item.original.segmentId}\n${item.original.slot}', + }; + if (!const SetEquality().equals( + detached, + choice.confirmedDetachedIds, + ) || + !const SetEquality().equals( + unmatched, + choice.confirmedUnmatchedExclusions, + ) || + !const SetEquality().equals( + plan.conflicts.possibleOverlaps.map((e) => e.id).toSet(), + choice.acknowledgedPossibleIds, + )) { + throw const BackupTimeReviewStale(); + } + final first = mapping.firstSlot; + if (first == null) throw const BackupTimeReviewStale(); + final segmentRecord = _store.record('segment', plan.anchor.segmentId)!; + final raw = _data._segment(segmentRecord['node'] as int); + final at = _now().toUtc(); + final base = plan.originalSchedule.copyWith( + scheduleTime: mapping.rule.start, + timeZoneId: mapping.rule.timeZoneId, + occurrenceOffsetSeconds: first.offsetSeconds, + ); + final sourceLead = + _data._definitionPreparation(raw.preparationId).totalDuration + + base.moveTime + + (base.scheduleSpareTime ?? Duration.zero); + final boundaries = [ + oldBoundary, + first.instantUtc.subtract(sourceLead), + plan.conflicts.earliestPreparationUtc!, + ]; + for (final item in mapping.rows) { + final value = _mappedSchedule(item, mapping.rule); + final resolved = ScheduleTimeResolver.resolve(value, nowUtc: at); + if (resolved.instantUtc == null || resolved.isHistorical) { + throw const BackupTimeReviewStale(); + } + boundaries.add(resolved.instantUtc!.subtract(_data._leadFor(value))); + } + if (boundaries.any((time) => !time.isAfter(at))) { + throw const BackupTimeReviewStale(); + } + final newId = const Uuid().v4(); + final edits = []; + void edit(String kind, String id, int parent, String field, Object? value) { + final node = _store.child(parent, field); + edits.add( + BackupRecurringScalarEdit( + kind: kind, + identity: id, + parent: parent, + field: field, + expectedExists: node != null, + expectedValue: node == null ? null : _store.scalar(node), + value: value, + ), + ); + } + + edit( + 'segment', + raw.id, + segmentRecord['node'] as int, + 'beforeSlot', + CivilDateTime.fromFields(plan.closeAt).toCivilIso8601String(), + ); + for (final item in mapping.rows) { + final value = _mappedSchedule(item, mapping.rule); + final row = _store.record('schedule', value.id)!; + final parent = row['node'] as int; + final mask = value.recurringOverrides + .split(',') + .where((bit) => bit.isNotEmpty) + .toSet(); + if (value.scheduleName != base.scheduleName) mask.add('name'); + if (value.place != base.place) mask.add('place'); + if (value.moveTime != base.moveTime) mask.add('move'); + if (value.scheduleSpareTime != base.scheduleSpareTime) mask.add('spare'); + if (value.scheduleNote != base.scheduleNote) mask.add('note'); + final fields = { + 'recurringSegmentId': item.detached ? null : newId, + 'recurringSlotKey': item.slot?.key, + 'recurringOrdinal': item.slot?.ordinal, + 'recurringOverrides': item.detached + ? '' + : (mask.toList()..sort()).join(','), + 'civilTime': CivilDateTime.fromFields( + value.scheduleTime, + ).toCivilIso8601String(), + 'timeZoneId': value.timeZoneId, + 'occurrenceOffsetSeconds': value.occurrenceOffsetSeconds, + }; + for (final entry in fields.entries) { + edit('schedule', value.id, parent, entry.key, entry.value); + } + } + final exclusions = { + for (final slot in [ + ...mapping.protectedSlots, + ...mapping.conflictExclusions, + for (final entry in mapping.exclusions) + if (entry.slot != null) entry.slot!, + ]) + slot.key: slot, + }; + final recurring = _store.child(_root, 'recurring')!; + final claimNow = _now().toUtc(); + _checkRecurringDraft(draft); + if (claimNow.isBefore(at) || + boundaries.any((time) => !time.isAfter(claimNow))) { + throw const BackupTimeReviewStale(); + } + _store.editRecurringTimePlan( + segmentArray: _store.child(recurring, 'segments')!, + exclusionArray: _store.child(recurring, 'exclusions')!, + edits: edits, + newSegment: { + 'id': newId, + 'seriesId': raw.seriesId, + 'preparationId': raw.preparationId, + 'fromSlot': CivilDateTime.fromFields( + mapping.rule.start, + ).toCivilIso8601String(), + 'beforeSlot': null, + 'createdAt': claimNow.millisecondsSinceEpoch, + 'preparationNotBefore': claimNow.millisecondsSinceEpoch, + 'ruleJson': jsonEncode(RecurrenceCodec.ruleToJson(mapping.rule)), + 'scheduleJson': jsonEncode(RecurrenceCodec.scheduleToJson(base)), + }, + newExclusions: [ + for (final slot in exclusions.values) + {'segmentId': newId, 'slotKey': slot.key, 'ordinal': slot.ordinal}, + ], + ); + boundaries.sort(); + _confirmedBoundaries[draft.issueId] = boundaries.first; + _revision++; + _structureChecked = false; + _issued.clear(); + _recurringIssued.clear(); + } +} diff --git a/lib/core/backup/backup_service.dart b/lib/core/backup/backup_service.dart index 78ee0c0e..b92c2f55 100644 --- a/lib/core/backup/backup_service.dart +++ b/lib/core/backup/backup_service.dart @@ -1,11 +1,24 @@ +import '../time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/ports/backup_file_import_port.dart'; +import 'backup_file_import_port.dart'; +import 'backup_ingestion_store.dart'; +import 'backup_validated_ingestion.dart'; +import 'backup_limits.dart'; +import 'package:on_time_front/core/database/restore_delivery_cleanup.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; export 'package:on_time_front/domain/entities/backup_operation.dart'; import 'package:on_time_front/domain/entities/backup_operation.dart'; -import 'package:on_time_front/core/backup/recurring_backup_data.dart'; -import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'backup_export_snapshot.dart'; +import 'backup_password.dart'; import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; import 'package:drift/drift.dart'; -import 'package:file_selector/file_selector.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/backup/backup_crypto.dart'; import 'package:on_time_front/core/backup/backup_file_export_port.dart'; @@ -14,14 +27,6 @@ import 'package:on_time_front/core/database/database.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:on_time_front/core/services/app_metadata_service.dart'; import 'package:on_time_front/core/services/device_info_service/shared.dart'; -import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; -import 'package:on_time_front/domain/entities/place_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; class BackupRestoreCandidate implements BackupRestoreInput { BackupRestoreCandidate._( @@ -29,6 +34,8 @@ class BackupRestoreCandidate implements BackupRestoreInput { this.preview, this._generation, this._revision, + this._staging, + this._lease, ); int _generation; @@ -36,9 +43,53 @@ class BackupRestoreCandidate implements BackupRestoreInput { bool _consumed = false; Future? _running; - final _BackupData _data; + final RestoreStaging _staging; + @override + Future dispose() async { + if (_running != null) await _running!.catchError((_) => -1); + _consumed = true; + await _releaseResources(); + } + + final BackupProcessingLease _lease; + final BackupValidatedIngestion _data; + Future _releaseResources() async { + if (_lease.phase == BackupProcessingPhase.cleanupPending) { + await _lease.retryCleanup(); + return; + } + Future cleanup() async { + Object? original; + try { + await _staging.release(); + } catch (error) { + original = error; + } + try { + await _data.release(); + } catch (error) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: error, + ); + } + if (original != null) throw original; + } + + try { + await cleanup(); + } catch (error) { + _lease.retainCleanup(cleanup); + rethrow; + } + _lease.release(); + } + @override final BackupRestorePreview preview; + @override + Future timeZoneImpacts({String? cursor}) => + _data.timeZoneImpacts(cursor: cursor); } @lazySingleton @@ -49,113 +100,482 @@ class BackupService { this._cancelAllAlarms, { @ignoreParam BackupCrypto? crypto, @ignoreParam BackupFileExportPort? exportPort, + @ignoreParam BackupFileImportPort? importPort, + @ignoreParam BackupProcessingOwner? processingOwner, + @ignoreParam + Future Function(BackupBudget)? ingestionFactory, @ignoreParam LocalDataOperationGate? operationGate, + @ignoreParam RestoreStagingFactory? stagingFactory, + @ignoreParam RestoreRuntimeIdentity? runtimeIdentity, + @ignoreParam Future Function()? cleanupPlatform, + @ignoreParam DateTime Function()? now, }) : _crypto = crypto ?? BackupCrypto(), _exportPort = exportPort ?? const NativeBackupFileExportPort(), - _operationGate = operationGate ?? LocalDataOperationGate.shared; - - static const _typeGroup = XTypeGroup( - label: 'OnTime Backup', - extensions: ['ontimebackup'], - mimeTypes: ['application/octet-stream'], - // iOS filters by UTI only. Files transferred from Android may not have a - // custom backup UTI, so authenticate their contents after selection. - uniformTypeIdentifiers: ['public.data'], - ); - + _importPort = importPort ?? const NativeBackupFileImportPort(), + _processingOwner = processingOwner ?? BackupProcessingOwner.shared, + _ingestionFactory = ingestionFactory ?? BackupIngestionStore.create, + _operationGate = operationGate ?? LocalDataOperationGate.shared, + _stagingFactory = stagingFactory ?? RestoreStaging.create, + _runtimeIdentity = runtimeIdentity ?? RestoreRuntimeIdentity.shared, + _cleanupPlatform = cleanupPlatform, + _now = now ?? DateTime.now; + + final DateTime Function() _now; + final Future Function()? _cleanupPlatform; + final RestoreStagingFactory _stagingFactory; + final RestoreRuntimeIdentity _runtimeIdentity; final AppDatabase _database; final CancelAllAlarmsUseCase _cancelAllAlarms; final AppMetadataProvider _metadataProvider; final BackupCrypto _crypto; final BackupFileExportPort _exportPort; + final BackupFileImportPort _importPort; + final BackupProcessingOwner _processingOwner; + final Future Function(BackupBudget) _ingestionFactory; final LocalDataOperationGate _operationGate; + final _pendingStagingCleanup = {}; + bool _restoreCommitUndetermined = false; int get generation => _operationGate.generation; - Future exportToUserSelectedFile( - String password, - ) => _operationGate.run(() async { + Future exportToUserSelectedFile(String password) async { + BackupPassword.parse(password); + final lease = _processingOwner.acquire()..beginOperation(); + try { + _PreparedExport? prepared; + var saved = false; + Object? original; + BackupExportResult? result; + try { + prepared = await _prepareExport(lease); + final receipt = await _exportPort.exportStream( + encrypted: _encryptedExport(prepared, password), + suggestedName: + 'OnTime-${_fileDate(prepared.snapshot.cutoff)}.ontimebackup', + lease: lease, + ); + saved = receipt == BackupFileExportReceipt.saved; + if (!saved) { + result = BackupExportResult.cancelled; + } else if (_operationGate.generation != prepared.generation) { + result = BackupExportResult.savedFreshnessUpdateFailed; + } else { + try { + await _database.userDao.markExported( + userId: localProfileId, + revision: prepared.snapshot.dataRevision, + cutoff: prepared.snapshot.cutoff, + ); + result = BackupExportResult.saved; + } catch (_) { + result = BackupExportResult.savedFreshnessUpdateFailed; + } + } + } catch (error) { + original = + error is BackupProcessingFailure || + error is BackupProcessingCleanupFailure + ? error + : const BackupFileExportFailure(); + } + await _finishExport(prepared, lease, original: original, saved: saved); + if (original != null) throw original; + return result!; + } finally { + lease.endOperation(); + } + } + + Future _createOwnedStaging( + BackupProcessingLease lease, + ) async { + try { + return await _stagingFactory(); + } on RestoreStagingCleanupFailure catch (error) { + if (error.retryCleanup != null) lease.retainCleanup(error.retryCleanup!); + throw BackupProcessingCleanupFailure( + originalError: error.originalError, + cleanupError: error.cleanupError, + ); + } + } + + Future<_PreparedExport> _prepareExport(BackupProcessingLease lease) async { + final budget = BackupBudget(lease: lease); final generation = _operationGate.generation; - final snapshot = await _captureSnapshot(); - final encrypted = await _encryptSnapshot(snapshot, password); - final BackupFileExportReceipt receipt; + final snapshot = await _operationGate.run(() async { + final metadata = await _metadataProvider.getMetadata(); + return BackupExportSnapshot.create( + _database, + cutoff: DateTime.now().toUtc(), + sourceAppVersion: '${metadata.version}+${metadata.buildNumber}', + sourcePlatform: _sourcePlatform(), + budget: budget, + stagingFactory: () => _createOwnedStaging(lease), + ); + }); try { - receipt = await _exportPort.export( - encryptedBytes: encrypted, - suggestedName: 'OnTime-${_fileDate(snapshot.cutoff)}.ontimebackup', + lease.update(BackupProcessingPhase.validating); + final digest = _ExportDigest(); + final hash = sha256.startChunkedConversion(digest); + Stream> counted() async* { + await for (final chunk in snapshot.plaintext()) { + budget.plaintext(chunk.length); + hash.add(chunk); + yield chunk; + } + hash.close(); + } + + final validated = await BackupValidatedIngestion.validateOwnedSnapshot( + plaintext: counted(), + budget: budget, + createStore: _ingestionFactory, + nowUtc: snapshot.cutoff, ); - } catch (_) { - throw const BackupFileExportFailure(); + try { + await validated.release(); + } catch (error) { + lease.retainCleanup(validated.release); + rethrow; + } + return _PreparedExport( + snapshot, + budget, + budget.plainBytes, + digest.value!, + generation, + ); + } catch (original) { + try { + await snapshot.release(); + } catch (cleanup) { + lease.retainCleanup(snapshot.release); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; } - if (receipt == BackupFileExportReceipt.cancelled) { - return BackupExportResult.cancelled; + } + + Stream> _encryptedExport( + _PreparedExport prepared, + String password, + ) { + final budget = prepared.budget.bytePass(); + budget.lease?.update(BackupProcessingPhase.encrypting); + Stream> verifiedSecondPass() async* { + final digest = _ExportDigest(); + final hash = sha256.startChunkedConversion(digest); + var length = 0; + await for (final chunk in prepared.snapshot.plaintext()) { + length += chunk.length; + if (length > prepared.length) BackupLimits.invalid(); + hash.add(chunk); + yield chunk; + } + hash.close(); + if (length != prepared.length || digest.value != prepared.digest) { + BackupLimits.invalid(); + } } - // A successful file save is distinct from updating this installation's - // freshness metadata. Never repeat or undo the external save on DB failure. - if (_operationGate.generation != generation) { - return BackupExportResult.savedFreshnessUpdateFailed; + + return _crypto.encryptStream( + plaintext: verifiedSecondPass(), + plaintextLength: prepared.length, + password: password, + budget: budget, + ); + } + + Future _finishExport( + _PreparedExport? prepared, + BackupProcessingLease lease, { + Object? original, + bool saved = false, + }) async { + try { + await prepared?.snapshot.release(); + } catch (cleanup) { + lease.retainCleanup(prepared!.snapshot.release); + if (!saved) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } } + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + } + + /// Authenticated selection can require explicit time review. A review owns + /// the same lease but has no active database or ready-preview authority. + Future reviewEncryptedBackup( + Uint8List encrypted, + String password, + ) async { + final lease = _processingOwner.acquire()..beginOperation(); try { - await _database.userDao.markExported( - userId: localProfileId, - revision: snapshot.dataRevision, - cutoff: snapshot.cutoff, - ); + return await _reviewStream(Stream.value(encrypted), password, lease); } catch (_) { - return BackupExportResult.savedFreshnessUpdateFailed; + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); } - return BackupExportResult.saved; - }); + } - Future selectAndPreviewRestore( + Future selectAndReviewRestore( String password, ) async { - final file = await openFile(acceptedTypeGroups: const [_typeGroup]); - if (file == null) return null; - return previewEncryptedBackup(await file.readAsBytes(), password); + final lease = _processingOwner.acquire()..beginOperation(); + BackupImportSource? source; + try { + source = await _importPort.select(lease: lease); + if (source == null) { + lease.release(); + return null; + } + return await _reviewStream(source.openRead(), password, lease); + } catch (original) { + try { + await source?.close(); + } catch (cleanup) { + lease.retainCleanup(() async { + await source?.close(); + }); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); + } } - /// Creates the same portable container used by the OS file export flow. - Future createEncryptedBackup(String password) async { - return _encryptSnapshot(await _captureSnapshot(), password); + Future _reviewStream( + Stream> encrypted, + String password, + BackupProcessingLease lease, + ) async { + final selectedGeneration = generation; + final selectedRevision = await _currentRevision(); + if (selectedGeneration != generation) { + throw const DataOperationException(DataOperationFailure.stalePreview); + } + return BackupAuthenticatedTimeReview.decrypt( + ciphertext: encrypted, + password: password, + crypto: _crypto, + budget: BackupBudget(lease: lease), + createStore: _ingestionFactory, + now: _now, + ready: (data) async { + RestoreStaging? staging; + try { + lease.check(); + if (!data.hasCurrentTimeAuthority(_now())) { + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + if (generation != selectedGeneration || + await _currentRevision() != selectedRevision) { + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + staging = await _createOwnedStaging(lease); + lease.check(); + await data.materialize(staging.database, pendingCleanup: false); + await data.validateReadBack(staging.database, pendingCleanup: false); + lease.check(); + if (!data.hasCurrentTimeAuthority(_now())) { + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + if (generation != selectedGeneration || + await _currentRevision() != selectedRevision) { + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + lease.update(BackupProcessingPhase.preview); + return BackupRestoreCandidate._( + data, + data.preview, + selectedGeneration, + selectedRevision, + staging, + lease, + ); + } catch (original) { + try { + await staging?.release(); + } catch (cleanup) { + lease.retainCleanup(() async { + await staging?.release(); + }); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + }, + ); } - /// Fully decrypts, authenticates, parses and validates a backup before apply. + Future selectAndPreviewRestore( + String password, + ) async { + final lease = _processingOwner.acquire()..beginOperation(); + BackupImportSource? source; + try { + source = await _importPort.select(lease: lease); + if (source == null) { + lease.release(); + return null; + } + return await _previewStream(source.openRead(), password, lease); + } catch (original) { + try { + await source?.close(); + } catch (cleanup) { + lease.retainCleanup(() async { + await source?.close(); + }); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); + } + } + + /// Explicit small/memory adapter, using the same authenticated bounded engine. Future previewEncryptedBackup( Uint8List encrypted, String password, ) async { - final decrypted = await _crypto.decrypt( - container: encrypted, - password: password, - ); - final decoded = jsonDecode(utf8.decode(decrypted)); - final data = _BackupData.fromJson(_asMap(decoded, 'backup')); - final previewGeneration = generation; - final revision = await _currentRevision(); - if (previewGeneration != generation) { - throw const DataOperationException(DataOperationFailure.stalePreview); + final lease = _processingOwner.acquire()..beginOperation(); + try { + return await _previewStream(Stream.value(encrypted), password, lease); + } catch (_) { + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); } - return BackupRestoreCandidate._( - data, - BackupRestorePreview( - cutoff: data.cutoff, - sourceAppVersion: data.sourceAppVersion, - sourcePlatform: data.sourcePlatform, - scheduleCount: data.schedules.length, - templateCount: data.templates.length, - defaultPreparationStepCount: - data.defaultPreparation.preparationStepList.length, - ), - previewGeneration, - revision, - ); } - Future _encryptSnapshot(_BackupData snapshot, String password) { - return _crypto.encrypt( - plaintext: Uint8List.fromList(utf8.encode(jsonEncode(snapshot.toJson()))), - password: password, - ); + Future _previewStream( + Stream> encrypted, + String password, + BackupProcessingLease lease, + ) async { + final selectedRules = TimeZoneRules.loadedIdentity; + BackupValidatedIngestion? data; + RestoreStaging? staging; + Future cleanup() async { + Object? original; + try { + await staging?.release(); + } catch (error) { + original = error; + } + try { + await data?.release(); + } catch (error) { + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: error, + ); + } + if (original != null) throw original; + } + + try { + data = await BackupValidatedIngestion.decrypt( + ciphertext: encrypted, + password: password, + crypto: _crypto, + budget: BackupBudget(lease: lease), + createStore: _ingestionFactory, + nowUtc: _now().toUtc(), + ); + data.establishTimeAuthority(selectedRules); + lease.update(BackupProcessingPhase.validating); + final previewGeneration = generation; + final revision = await _currentRevision(); + if (previewGeneration != generation || + !data.hasCurrentTimeAuthority(_now())) { + throw const DataOperationException(DataOperationFailure.stalePreview); + } + staging = await _createOwnedStaging(lease); + await data.materialize(staging.database, pendingCleanup: false); + await data.validateReadBack(staging.database, pendingCleanup: false); + lease.check(); + if (previewGeneration != generation || + !data.hasCurrentTimeAuthority(_now())) { + throw const DataOperationException(DataOperationFailure.stalePreview); + } + lease.update(BackupProcessingPhase.preview); + return BackupRestoreCandidate._( + data, + data.preview, + previewGeneration, + revision, + staging, + lease, + ); + } catch (original) { + try { + await cleanup(); + } catch (error) { + lease.retainCleanup(cleanup); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: error, + ); + } + rethrow; + } + } + + /// Explicit memory output adapter retained for fixtures and small callers. + Future createEncryptedBackup(String password) async { + BackupPassword.parse(password); + final lease = _processingOwner.acquire()..beginOperation(); + _PreparedExport? prepared; + Object? original; + try { + prepared = await _prepareExport(lease); + final output = BytesBuilder(copy: false); + await for (final chunk in _encryptedExport(prepared, password)) { + output.add(chunk); + } + return output.takeBytes(); + } catch (error) { + original = error; + rethrow; + } finally { + try { + await _finishExport(prepared, lease, original: original); + } finally { + lease.endOperation(); + } + } } Future _currentRevision() async => (await (_database.select( @@ -172,9 +592,12 @@ class BackupService { const DataOperationException(DataOperationFailure.stalePreview), ); } - return candidate._running ??= _claimRestore( - candidate, - ).whenComplete(() => candidate._running = null); + if (candidate._running != null) return candidate._running!; + candidate._lease.beginOperation(); + return candidate._running = _claimRestore(candidate).whenComplete(() { + candidate._running = null; + candidate._lease.endOperation(); + }); } Future _claimRestore(BackupRestoreCandidate candidate) async { @@ -184,14 +607,24 @@ class BackupService { return await _operationGate.run( () async { claimed = true; + candidate._lease.cancellationAllowed = false; + candidate._lease.update(BackupProcessingPhase.applying); claimGeneration = generation; await _applyRestore(candidate); candidate._consumed = true; + _pendingStagingCleanup.add(candidate); + // A committed restore is never retried as another data replacement. + try { + await finishRestoreCleanup(); + } catch (_) { + _operationGate.setRecoveryPending(true); + } return claimGeneration; }, replacesData: true, validateReplacement: () async { - if (candidate._generation != generation || + if (!candidate._data.hasCurrentTimeAuthority(_now()) || + candidate._generation != generation || candidate._revision != await _currentRevision()) { throw const DataOperationException( DataOperationFailure.stalePreview, @@ -200,6 +633,7 @@ class BackupService { }, ); } catch (error) { + if (error is BackupRestoreCommitUncertain) rethrow; if (!claimed) rethrow; // The old generation and OS cleanup may already have changed, even when // the DB transaction rejects an edit that raced the cleanup. A09 owns @@ -215,61 +649,101 @@ class BackupService { } } + Future finishRestoreCleanup() async { + if (_restoreCommitUndetermined) { + throw BackupRestoreCommitUncertain(generation: generation); + } + await _runtimeIdentity.cleanup( + _database, + _operationGate, + cleanupPlatform: () async { + await (_cleanupPlatform ?? + () => cleanupRestoreDeliveries(_cancelAllAlarms.operations))(); + // Keep the durable pending marker until both OS/runtime cleanup and the + // candidate's encrypted staging file have been released successfully. + for (final staging in _pendingStagingCleanup.toList()) { + await staging._releaseResources(); + _pendingStagingCleanup.remove(staging); + } + }, + ); + } + Future _applyRestore(BackupRestoreCandidate candidate) async { - await _cancelAllAlarms.forDataReplacement(); - final data = candidate._data; - final profile = data.profile.valueOrNull!; - await _database.transaction(() async { - if (candidate._revision != await _currentRevision()) { - throw const DataOperationException(DataOperationFailure.stalePreview); - } - await _database.deleteAllDurableData(); - await _database - .into(_database.users) - .insert( - UsersCompanion.insert( - id: const Value(localProfileId), - spareTime: profile.spareTime.inMinutes, - note: profile.note, - isOnboardingCompleted: Value(profile.isOnboardingCompleted), - eligibleOutcomeCount: Value(profile.eligibleOutcomeCount), - onTimeOutcomeCount: Value(profile.onTimeOutcomeCount), - alarmsEnabled: Value(data.alarmsEnabled), - alarmOffsetMinutes: Value(data.alarmOffsetMinutes), - detailedNotificationContent: Value( - data.detailedNotificationContent, - ), - dataRevision: Value(data.dataRevision + 1), - firstDurableDataAt: Value(data.cutoff), - lastDurableDataAt: Value(data.cutoff), - ), - ); - await _database.preparationUserDao.createPreparationUser( - data.defaultPreparation, - localProfileId, - ); - await data.recurring.restore(_database); - for (final schedule in data.schedules) { - await _database.scheduleDao.createSchedule( - schedule.toScheduleWithPlaceRow(), - ); - final preparation = data.schedulePreparations[schedule.id]; - if (preparation != null && preparation.preparationStepList.isNotEmpty) { - await _database.preparationScheduleDao.createPreparationSchedule( - preparation, - schedule.id, - ); + try { + await _cancelAllAlarms.forDataReplacement(); + } on AlarmCleanupIncomplete { + // Returned failure is distinct from an outstanding native Future. + // Ownership journal still blocks same-ID replacement until confirmed. + } + User? original; + User? attempted; + try { + await _database.transaction(() async { + original = await _database.select(_database.users).getSingle(); + if (!candidate._data.hasCurrentTimeAuthority(_now()) || + candidate._revision != await _currentRevision()) { + throw const DataOperationException(DataOperationFailure.stalePreview); } - } - for (final template in data.templates) { - await _database.preparationTemplateDao.put( - id: template.id, - name: template.name, - preparation: template.preparation, - now: template.updatedAt, + await _database.deleteAllDurableData(); + await copyPortableBackupRows( + candidate._staging.database, + _database, + budget: candidate._data.budget, + ); + final durableMarker = candidate._data.metadata.cutoffLiteral == null + ? candidate._data.metadata.cutoff + : DateTime.now().toUtc(); + await (_database.update( + _database.users, + )..where((u) => u.id.equals(localProfileId))).write( + UsersCompanion( + restoreCleanupPending: const Value(true), + rejectLegacyDelivery: const Value(true), + firstDurableDataAt: Value(durableMarker), + lastDurableDataAt: Value(durableMarker), + ), + ); + await _database.customStatement( + "UPDATE schedules SET requires_start_confirmation=1 WHERE done_status='notEnded'", ); + attempted = await _database.select(_database.users).getSingle(); + }); + } catch (_) { + // If the callback did not finish, Drift never attempted its outer COMMIT. + // Once it did finish, an exception may only be a lost commit response. + if (attempted == null) rethrow; + User? current; + try { + current = await _database.select(_database.users).getSingleOrNull(); + } catch (_) { + await _holdUnknownRestore(candidate); + throw BackupRestoreCommitUncertain(generation: generation); } - }); + if (current == original) rethrow; // Authoritative rollback read-back. + if (current != attempted) { + await _holdUnknownRestore(candidate); + throw BackupRestoreCommitUncertain(generation: generation); + } + // The exact new profile includes a fresh store incarnation and the + // pending cleanup marker written with all portable rows atomically. + // Continue the committed path; never apply this candidate a second time. + } + _operationGate.setRecoveryPending(true); + } + + Future _holdUnknownRestore(BackupRestoreCandidate candidate) async { + _restoreCommitUndetermined = true; + _operationGate.setRecoveryPending(true); + _runtimeIdentity.pending = true; + candidate._consumed = true; + // Provisional resources are no longer needed for another import. Existing + // lease cleanup retains any failure; durable authority waits for restart. + try { + await candidate._releaseResources(); + } catch (_) { + // Cleanup failure cannot turn an unknown replacement into a rollback. + } } Future getFreshness() async { @@ -296,39 +770,6 @@ class BackupService { bool _isOlderThanReminderBoundary(DateTime? value) => value != null && DateTime.now().difference(value).inDays >= 30; - Future<_BackupData> _captureSnapshot() async { - return _database.transaction(() async { - final cutoff = DateTime.now(); - final metadata = await _metadataProvider.getMetadata(); - final user = await (_database.select( - _database.users, - )..where((table) => table.id.equals(localProfileId))).getSingle(); - final scheduleRows = await _database.scheduleDao.getScheduleList(); - final schedulePreparations = {}; - for (final row in scheduleRows) { - schedulePreparations[row.schedule.id] = await _database - .preparationScheduleDao - .getPreparationSchedulesByScheduleId(row.schedule.id); - } - return _BackupData( - cutoff: cutoff, - sourceAppVersion: '${metadata.version}+${metadata.buildNumber}', - sourcePlatform: _sourcePlatform(), - dataRevision: user.dataRevision, - profile: user.toUserEntity(), - alarmsEnabled: user.alarmsEnabled, - alarmOffsetMinutes: user.alarmOffsetMinutes, - detailedNotificationContent: user.detailedNotificationContent, - schedules: scheduleRows.map((row) => row.toScheduleEntity()).toList(), - defaultPreparation: await _database.preparationUserDao - .getPreparationUsersByUserId(localProfileId), - schedulePreparations: schedulePreparations, - templates: await _database.preparationTemplateDao.getAll(), - recurring: await RecurringBackupData.capture(_database), - ); - }); - } - String _fileDate(DateTime value) => '${value.year.toString().padLeft(4, '0')}' '${value.month.toString().padLeft(2, '0')}' @@ -343,403 +784,28 @@ class BackupService { } } -class _BackupData { - const _BackupData({ - required this.cutoff, - required this.sourceAppVersion, - required this.sourcePlatform, - required this.dataRevision, - required this.profile, - required this.alarmsEnabled, - required this.alarmOffsetMinutes, - required this.detailedNotificationContent, - required this.schedules, - required this.defaultPreparation, - required this.schedulePreparations, - required this.templates, - this.recurring = const RecurringBackupData(), - }); - - final DateTime cutoff; - final String sourceAppVersion; - final String sourcePlatform; - final int dataRevision; - final UserEntity profile; - final bool alarmsEnabled; - final int alarmOffsetMinutes; - final bool detailedNotificationContent; - final List schedules; - final PreparationEntity defaultPreparation; - final Map schedulePreparations; - final List templates; - final RecurringBackupData recurring; - - Map toJson() => { - 'formatVersion': 2, - 'recurring': recurring.toJson(), - 'cutoff': cutoff.toIso8601String(), - 'sourceAppVersion': sourceAppVersion, - 'sourcePlatform': sourcePlatform, - 'dataRevision': dataRevision, - 'profile': { - 'spareTimeMinutes': profile.valueOrNull!.spareTime.inMinutes, - 'note': profile.valueOrNull!.note, - 'isOnboardingCompleted': profile.valueOrNull!.isOnboardingCompleted, - 'eligibleOutcomeCount': profile.valueOrNull!.eligibleOutcomeCount, - 'onTimeOutcomeCount': profile.valueOrNull!.onTimeOutcomeCount, - }, - 'preferences': { - 'alarmsEnabled': alarmsEnabled, - 'alarmOffsetMinutes': alarmOffsetMinutes, - 'detailedNotificationContent': detailedNotificationContent, - }, - 'schedules': schedules.map(_scheduleToJson).toList(), - 'defaultPreparation': _preparationToJson(defaultPreparation), - 'schedulePreparations': { - for (final entry in schedulePreparations.entries) - entry.key: _preparationToJson(entry.value), - }, - 'templates': templates - .map( - (template) => { - 'id': template.id, - 'name': template.name, - 'createdAt': template.createdAt.toIso8601String(), - 'updatedAt': template.updatedAt.toIso8601String(), - 'preparation': _preparationToJson(template.preparation), - }, - ) - .toList(), - }; - - factory _BackupData.fromJson(Map json) { - final version = _asInt(json['formatVersion'], 'formatVersion'); - if (version != 1 && version != 2) { - throw const FormatException('Unsupported backup data version.'); - } - final profile = _asMap(json['profile'], 'profile'); - final preferences = _asMap(json['preferences'], 'preferences'); - final schedulePreparations = _asMap( - json['schedulePreparations'], - 'schedulePreparations', - ); - final templates = _asList(json['templates'], 'templates'); - final result = _BackupData( - recurring: version == 1 - ? const RecurringBackupData() - : RecurringBackupData.fromJson(json['recurring']), - cutoff: _asDate(json['cutoff'], 'cutoff'), - sourceAppVersion: _asString(json['sourceAppVersion'], 'sourceAppVersion'), - sourcePlatform: _asString(json['sourcePlatform'], 'sourcePlatform'), - dataRevision: _asNonNegativeInt(json['dataRevision'], 'dataRevision'), - profile: UserEntity( - id: localProfileId, - spareTime: Duration( - minutes: _asNonNegativeInt( - profile['spareTimeMinutes'], - 'profile.spareTimeMinutes', - ), - ), - note: _asString(profile['note'], 'profile.note'), - isOnboardingCompleted: _asBool( - profile['isOnboardingCompleted'], - 'profile.isOnboardingCompleted', - ), - eligibleOutcomeCount: _asNonNegativeInt( - profile['eligibleOutcomeCount'], - 'profile.eligibleOutcomeCount', - ), - onTimeOutcomeCount: _asNonNegativeInt( - profile['onTimeOutcomeCount'], - 'profile.onTimeOutcomeCount', - ), - ), - alarmsEnabled: _asBool( - preferences['alarmsEnabled'], - 'preferences.alarmsEnabled', - ), - alarmOffsetMinutes: _asNonNegativeInt( - preferences['alarmOffsetMinutes'], - 'preferences.alarmOffsetMinutes', - ), - detailedNotificationContent: _asBool( - preferences['detailedNotificationContent'], - 'preferences.detailedNotificationContent', - ), - schedules: _asList( - json['schedules'], - 'schedules', - ).map((value) => _scheduleFromJson(_asMap(value, 'schedule'))).toList(), - defaultPreparation: _preparationFromJson( - _asList(json['defaultPreparation'], 'defaultPreparation'), - ), - schedulePreparations: { - for (final entry in schedulePreparations.entries) - entry.key: _preparationFromJson( - _asList(entry.value, 'schedulePreparations.${entry.key}'), - ), - }, - templates: templates.map((value) { - final map = _asMap(value, 'template'); - return PreparationTemplateEntity( - id: _asString(map['id'], 'template.id'), - name: _asString(map['name'], 'template.name'), - createdAt: _asDate(map['createdAt'], 'template.createdAt'), - updatedAt: _asDate(map['updatedAt'], 'template.updatedAt'), - preparation: _preparationFromJson( - _asList(map['preparation'], 'template.preparation'), - ), - ); - }).toList(), - ); - _validateBackupData(result); - return result; - } -} - -void _validateBackupData(_BackupData data) { - data.recurring.validate(data.schedules); - final profile = data.profile.valueOrNull!; - if (profile.onTimeOutcomeCount > profile.eligibleOutcomeCount) { - throw const FormatException( - 'On-time outcome count cannot exceed eligible outcome count.', - ); - } - if (data.alarmOffsetMinutes > 24 * 60) { - throw const FormatException('Alarm offset is outside the supported range.'); - } - final scheduleIds = {}; - for (final schedule in data.schedules) { - if (schedule.id.isEmpty || !scheduleIds.add(schedule.id)) { - throw const FormatException('Schedule identifiers must be unique.'); - } - if (schedule.timeZoneId.isEmpty || - (schedule.occurrenceOffsetSeconds?.abs() ?? 0) > 24 * 60 * 60) { - throw const FormatException('Schedule time-zone data is invalid.'); - } - } - if (!data.schedulePreparations.keys.every(scheduleIds.contains)) { - throw const FormatException( - 'Schedule preparation references an unknown schedule.', - ); - } - final templateIds = {}; - for (final template in data.templates) { - if (template.id.isEmpty || !templateIds.add(template.id)) { - throw const FormatException('Template identifiers must be unique.'); - } - } - _validatePreparation(data.defaultPreparation); - for (final preparation in data.schedulePreparations.values) { - _validatePreparation(preparation); - } - for (final template in data.templates) { - _validatePreparation(template.preparation); - } -} - -void _validatePreparation(PreparationEntity preparation) { - final ids = {}; - for (final step in preparation.preparationStepList) { - if (step.id.isEmpty || !ids.add(step.id)) { - throw const FormatException( - 'Preparation step identifiers must be unique.', - ); - } - } - for (final step in preparation.preparationStepList) { - final nextId = step.nextPreparationId; - if (nextId != null && !ids.contains(nextId)) { - throw const FormatException( - 'Preparation step references an unknown next step.', - ); - } - } -} - -Map _scheduleToJson(ScheduleEntity value) => { - 'id': value.id, - 'place': {'id': value.place.id, 'name': value.place.placeName}, - 'name': value.scheduleName, - 'civilTime': value.scheduleTime.toIso8601String(), - 'timeZoneId': value.timeZoneId, - 'occurrenceOffsetSeconds': value.occurrenceOffsetSeconds, - 'moveTimeMinutes': value.moveTime.inMinutes, - 'isChanged': value.isChanged, - 'spareTimeMinutes': value.scheduleSpareTime?.inMinutes, - 'note': value.scheduleNote, - 'latenessTime': value.latenessTime, - 'doneStatus': value.doneStatus.name, - 'finishedAt': value.finishedAt?.toIso8601String(), - 'preparationMode': value.preparationMode?.name, - 'preparationTemplateId': value.preparationTemplateId, - 'preparationTemplateName': value.preparationTemplateName, - 'preparationTemplateDeleted': value.preparationTemplateDeleted, - 'scoreContributionRecorded': value.scoreContributionRecorded, - 'recurringSegmentId': value.recurringSegmentId, - 'recurringSlotKey': value.recurringSlotKey, - 'recurringOrdinal': value.recurringOrdinal, - 'recurringOverrides': value.recurringOverrides, - 'preparationDefinitionId': value.preparationDefinitionId, -}; - -ScheduleEntity _scheduleFromJson(Map json) { - final place = _asMap(json['place'], 'schedule.place'); - final eligibleCount = _nullableInt( - json['occurrenceOffsetSeconds'], - 'schedule.occurrenceOffsetSeconds', - ); - return ScheduleEntity( - recurringSegmentId: _nullableString( - json['recurringSegmentId'], - 'schedule.recurringSegmentId', - ), - recurringSlotKey: _nullableString( - json['recurringSlotKey'], - 'schedule.recurringSlotKey', - ), - recurringOrdinal: _nullableInt( - json['recurringOrdinal'], - 'schedule.recurringOrdinal', - ), - recurringOverrides: - _nullableString( - json['recurringOverrides'], - 'schedule.recurringOverrides', - ) ?? - '', - preparationDefinitionId: _nullableString( - json['preparationDefinitionId'], - 'schedule.preparationDefinitionId', - ), - id: _asString(json['id'], 'schedule.id'), - place: PlaceEntity( - id: _asString(place['id'], 'schedule.place.id'), - placeName: _asString(place['name'], 'schedule.place.name'), - ), - scheduleName: _asString(json['name'], 'schedule.name'), - scheduleTime: _asDate(json['civilTime'], 'schedule.civilTime'), - timeZoneId: _asString(json['timeZoneId'], 'schedule.timeZoneId'), - occurrenceOffsetSeconds: eligibleCount, - moveTime: Duration( - minutes: _asNonNegativeInt( - json['moveTimeMinutes'], - 'schedule.moveTimeMinutes', - ), - ), - isChanged: _asBool(json['isChanged'], 'schedule.isChanged'), - isStarted: false, - scheduleSpareTime: _nullableInt( - json['spareTimeMinutes'], - 'schedule.spareTimeMinutes', - )?.let((minutes) => Duration(minutes: minutes)), - scheduleNote: _asString(json['note'], 'schedule.note'), - latenessTime: _asInt(json['latenessTime'], 'schedule.latenessTime'), - doneStatus: ScheduleDoneStatus.values.byName( - _asString(json['doneStatus'], 'schedule.doneStatus'), - ), - startedAt: null, - finishedAt: _nullableDate(json['finishedAt'], 'schedule.finishedAt'), - preparationMode: json['preparationMode'] == null - ? null - : SchedulePreparationMode.values.byName( - _asString(json['preparationMode'], 'schedule.preparationMode'), - ), - preparationTemplateId: _nullableString( - json['preparationTemplateId'], - 'schedule.preparationTemplateId', - ), - preparationTemplateName: _nullableString( - json['preparationTemplateName'], - 'schedule.preparationTemplateName', - ), - preparationTemplateDeleted: _asBool( - json['preparationTemplateDeleted'], - 'schedule.preparationTemplateDeleted', - ), - preparationFrozen: false, - scoreContributionRecorded: _asBool( - json['scoreContributionRecorded'], - 'schedule.scoreContributionRecorded', - ), +class _PreparedExport { + _PreparedExport( + this.snapshot, + this.budget, + this.length, + this.digest, + this.generation, ); + final BackupExportSnapshot snapshot; + final BackupBudget budget; + final int length; + final Digest digest; + final int generation; } -List> _preparationToJson(PreparationEntity value) => [ - for (final step in value.ordered.preparationStepList) - { - 'id': step.id, - 'name': step.preparationName, - 'minutes': step.preparationTime.inMinutes, - 'nextId': step.nextPreparationId, - }, -]; - -PreparationEntity _preparationFromJson(List values) { - return PreparationEntity( - preparationStepList: values.map((value) { - final map = _asMap(value, 'preparationStep'); - return PreparationStepEntity( - id: _asString(map['id'], 'preparationStep.id'), - preparationName: _asString(map['name'], 'preparationStep.name'), - preparationTime: Duration( - minutes: _asNonNegativeInt(map['minutes'], 'preparationStep.minutes'), - ), - nextPreparationId: _nullableString( - map['nextId'], - 'preparationStep.nextId', - ), - ); - }).toList(), - ).ordered; -} - -Map _asMap(Object? value, String field) { - if (value is Map) return value; - throw FormatException('$field must be an object.'); -} - -List _asList(Object? value, String field) { - if (value is List) return value; - throw FormatException('$field must be a list.'); -} - -String _asString(Object? value, String field) { - if (value is String) return value; - throw FormatException('$field must be a string.'); -} - -String? _nullableString(Object? value, String field) => - value == null ? null : _asString(value, field); - -int _asInt(Object? value, String field) { - if (value is int) return value; - throw FormatException('$field must be an integer.'); -} - -int _asNonNegativeInt(Object? value, String field) { - final result = _asInt(value, field); - if (result < 0) throw FormatException('$field must not be negative.'); - return result; -} - -int? _nullableInt(Object? value, String field) => - value == null ? null : _asInt(value, field); - -bool _asBool(Object? value, String field) { - if (value is bool) return value; - throw FormatException('$field must be a boolean.'); -} - -DateTime _asDate(Object? value, String field) { - final parsed = DateTime.tryParse(_asString(value, field)); - if (parsed == null) throw FormatException('$field must be an ISO-8601 date.'); - return parsed; -} - -DateTime? _nullableDate(Object? value, String field) => - value == null ? null : _asDate(value, field); +class _ExportDigest implements Sink { + Digest? value; + @override + void add(Digest data) { + value = data; + } -extension _Let on T { - R let(R Function(T value) action) => action(this); + @override + void close() {} } diff --git a/lib/core/backup/backup_time_conflict_world.dart b/lib/core/backup/backup_time_conflict_world.dart new file mode 100644 index 00000000..d4820107 --- /dev/null +++ b/lib/core/backup/backup_time_conflict_world.dart @@ -0,0 +1,141 @@ +part of 'backup_validated_ingestion.dart'; + +/// Candidate-only read adapter. It never opens AppDatabase or calls an active +/// repository, and every scan spends the ingestion owner's original budget. +class _BackupConflictWorld { + _BackupConflictWorld( + this.rows, + this.segments, + this.exclusions, + this.possible, + ); + final List rows; + final List segments; + final Map> exclusions; + final List possible; + + static Future<_BackupConflictWorld> read( + BackupValidatedIngestion data, + DateTime now, + Set ignoredIds, { + Map closingSegments = const {}, + }) async { + final store = data.store; + final rows = []; + final segments = []; + final exclusions = >{}; + final possible = []; + for (final row in store.records('exclusion')) { + (exclusions[row['reference'] as String] ??= {}).add( + row['slot'] as String, + ); + } + for (final record in store.records('schedule')) { + final value = data._schedule(record['node'] as int); + if (value.recurringSegmentId != null && value.recurringSlotKey != null) { + (exclusions[value.recurringSegmentId!] ??= {}).add( + value.recurringSlotKey!, + ); + } + if (ignoredIds.contains(value.id) || + value.doneStatus != ScheduleDoneStatus.notEnded) { + continue; + } + if (value.recurringSegmentId != null) { + final segment = store.record('segment', value.recurringSegmentId!)!; + final raw = data._segment(segment['node'] as int); + if (RecurrenceReferencePolicy.isClosedTail( + value.recurringSlotKey!, + raw.beforeSlot, + )) { + continue; + } + } + final resolution = ScheduleTimeResolver.resolve(value, nowUtc: now); + final target = resolution.instantUtc; + if (target == null) { + if (resolution.isHistorical) continue; + final lead = data._leadFor(value); + final civil = CivilDateTime.fromFields( + value.scheduleTime, + ).toUtcCarrier(); + final earliest = civil.subtract(const Duration(days: 1) + lead); + final latest = civil.add(const Duration(days: 1)); + if (latest.isBefore(now)) continue; + possible.add( + TimeCorrectionPossibleOverlap( + id: value.id, + name: value.scheduleName, + reason: resolution.status.name, + originalCivil: value.scheduleTime, + timeZoneId: value.timeZoneId, + earliestPreparationUtc: earliest, + latestTargetUtc: latest, + ), + ); + } else { + rows.add( + TimeCorrectionBusyInterval( + value, + target.subtract(data._leadFor(value)), + target, + ), + ); + } + } + for (final record in store.records('segment')) { + final raw = data._segment(record['node'] as int); + final spec = await data._spec(raw); + final from = CivilDateTime.parse(raw.fromSlot).toUtcCarrier(); + var before = raw.beforeSlot == null + ? null + : CivilDateTime.parse(raw.beforeSlot!).toUtcCarrier(); + final closing = closingSegments[raw.id]; + if (closing != null && (before == null || closing.isBefore(before))) { + before = closing; + } + if (before != null && !before.isAfter(from)) continue; + final preparation = data._definitionPreparation(raw.preparationId); + final value = RecurringSegment( + id: raw.id, + seriesId: raw.seriesId, + rule: spec.rule, + schedule: spec.schedule, + preparation: preparation, + preparationId: raw.preparationId, + fromSlot: from, + beforeSlot: before, + createdAt: raw.createdAt, + preparationNotBefore: raw.preparationNotBefore, + ); + if (!TimeZoneRules.contains(spec.rule.timeZoneId)) { + if (RecurrenceReferencePolicy.hasNoFutureGeneration( + rule: spec.rule, + fromSlot: from, + beforeSlot: before, + nowUtc: now, + )) { + continue; + } + final end = before ?? spec.rule.until; + possible.add( + TimeCorrectionPossibleOverlap( + id: 'segment:${raw.id}', + name: spec.schedule.scheduleName, + reason: 'unknownZone', + originalCivil: spec.rule.start, + timeZoneId: spec.rule.timeZoneId, + rule: spec.rule, + earliestPreparationUtc: spec.rule.start.subtract( + const Duration(days: 1) + value.leadTime, + ), + latestTargetUtc: end?.add(const Duration(days: 2)), + ), + ); + } else { + segments.add(value); + } + } + return _BackupConflictWorld(rows, segments, exclusions, possible); + } +} diff --git a/lib/core/backup/backup_validated_ingestion.dart b/lib/core/backup/backup_validated_ingestion.dart new file mode 100644 index 00000000..49b699b9 --- /dev/null +++ b/lib/core/backup/backup_validated_ingestion.dart @@ -0,0 +1,1736 @@ +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/entities/backup_recurring_time_review.dart'; +import 'package:on_time_front/data/mappers/recurrence_codec.dart'; +import '../time/recurring_time_correction_mapping.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import '../time/schedule_time_resolution.dart'; +import '../time/time_correction_conflicts.dart'; +import 'dart:convert'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import '../time/civil_time_resolver.dart'; +import '../time/time_zone_rules.dart'; +import 'package:collection/collection.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_reference_policy.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'backup_recurrence_scan.dart'; +import 'backup_content.dart'; +import 'backup_crypto.dart'; +import 'backup_ingestion_store.dart'; +import 'backup_json_reader.dart'; +import 'backup_limits.dart'; +import 'backup_value_validation.dart'; + +part 'backup_authenticated_time_review.dart'; +part 'backup_time_conflict_world.dart'; +part 'backup_recurring_time_review.dart'; + +/// Authenticated, disk-indexed portable records. This owner never captures a +/// whole BackupContent or opens the active/original database to validate input. +class BackupValidatedIngestion { + BackupValidatedIngestion._( + this.store, + this.root, + this.nowUtc, [ + this.offsetLookup = backupOffsets, + this._collectTimeIssues = false, + ]); + final BackupIngestionStore store; + final int root; + final DateTime nowUtc; + final BackupOffsetLookup offsetLookup; + final bool _collectTimeIssues; + int timeIssueCount = 0; + late final BackupContent + metadata; // Bounded header only, all collections empty. + late final int _default; + int _scheduleCount = 0; + int _templateCount = 0; + int _defaultCount = 0; + int timezoneChangeCount = 0; + int uncertainHistoryCount = 0; + final _uncertainExamples = []; + bool _validated = false; + final _specCache = {}; + BackupBudget get budget => store.budget; + + static Future decrypt({ + required Stream> ciphertext, + required String password, + required BackupCrypto crypto, + required BackupBudget budget, + Future Function(BackupBudget)? createStore, + DateTime? nowUtc, + }) async { + final store = await (createStore ?? BackupIngestionStore.create)(budget); + try { + final root = await BackupJsonReader(store, budget, portableRecords: true) + .read( + crypto.decryptStream( + container: ciphertext, + password: password, + budget: budget, + ), + ); + // decryptStream has checked final authentication, frameCount and actual EOF. + store.finish(); + final result = BackupValidatedIngestion._( + store, + root, + (nowUtc ?? DateTime.now()).toUtc(), + ); + await store.validateRecords(result._validate); + result._validated = true; + return result; + } catch (original) { + try { + await store.release(); + } catch (cleanup) { + budget.lease?.retainCleanup(store.release); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + /// Internal owned snapshot admission; no user-supplied plaintext file enters + /// this boundary. The source is an immutable encrypted export snapshot. + static Future validateOwnedSnapshot({ + required Stream> plaintext, + required BackupBudget budget, + required Future Function(BackupBudget) createStore, + required DateTime nowUtc, + }) async { + final store = await createStore(budget); + try { + final root = await BackupJsonReader( + store, + budget, + portableRecords: true, + ).read(plaintext); + store.finish(); + final result = BackupValidatedIngestion._(store, root, nowUtc.toUtc()); + await store.validateRecords(result._validate); + result._validated = true; + return result; + } catch (original) { + try { + await store.release(); + } catch (cleanup) { + budget.lease?.retainCleanup(store.release); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + /// Explicit plaintext test adapter. Production enters only through decrypt(). + static Future validateTestStore( + BackupIngestionStore store, + int root, { + required DateTime nowUtc, + BackupOffsetLookup offsetLookup = backupOffsets, + }) async { + store.finish(); + final value = BackupValidatedIngestion._( + store, + root, + nowUtc.toUtc(), + offsetLookup, + ); + await store.validateRecords(value._validate); + value._validated = true; + return value; + } + + final _effectivePreparationCache = {}; + + PreparationEntity _definitionPreparation(String id) { + final steps = [ + for (final row in store.records('definitionStep', owner: id)) + _definitionStep(row['node'] as int), + ]..sort((a, b) => a.position.compareTo(b.position)); + return PreparationEntity( + preparationStepList: [ + for (var i = 0; i < steps.length; i++) + PreparationStepEntity( + id: steps[i].id, + preparationName: steps[i].name, + preparationTime: Duration(minutes: steps[i].minutes), + nextPreparationId: i + 1 < steps.length ? steps[i + 1].id : null, + ), + ], + ); + } + + PreparationEntity _effectivePreparation(ScheduleEntity schedule) { + final existing = _effectivePreparationCache[schedule.id]; + if (existing != null) return existing; + PreparationEntity? preparation; + if (!schedule.preparationFrozen && + schedule.preparationMode == SchedulePreparationMode.template && + schedule.preparationTemplateId != null && + !schedule.preparationTemplateDeleted) { + final template = store.record( + 'template', + schedule.preparationTemplateId!, + ); + if (template != null) { + preparation = _template(template['node'] as int).preparation; + } + } + if (preparation == null) { + final own = store.record('schedulePreparation', schedule.id); + preparation = schedule.preparationDefinitionId != null + ? _definitionPreparation(schedule.preparationDefinitionId!) + : own == null + ? const PreparationEntity(preparationStepList: []) + : _preparation(own['node'] as int, 'scheduleStep', schedule.id); + if (!schedule.preparationFrozen && + preparation.preparationStepList.isEmpty) { + preparation = _preparation(_default, 'userStep', localProfileId); + } + } + if (_effectivePreparationCache.length == 16) { + _effectivePreparationCache.remove(_effectivePreparationCache.keys.first); + } + _effectivePreparationCache[schedule.id] = preparation; + return preparation; + } + + Duration _leadFor(ScheduleEntity schedule) => + _effectivePreparation(schedule).totalDuration + + schedule.moveTime + + (schedule.scheduleSpareTime ?? Duration.zero); + + String? _authorityRules; + DateTime? _authorityBefore; + + /// A ready preview is valid only while its time classifications remain valid. + /// This bound is separate from the active-store generation/revision authority. + void establishTimeAuthority(String rulesIdentity) { + if (!_validated) throw StateError('No provisional time authority'); + _authorityRules = rulesIdentity; + void boundary(DateTime value) { + if (!value.isBefore(nowUtc) && + (_authorityBefore == null || value.isBefore(_authorityBefore!))) { + _authorityBefore = value; + } + } + + for (final row in store.records('schedule')) { + final schedule = _schedule(row['node'] as int); + if (RecurrenceReferencePolicy.hasProtectedFacts(schedule) || + !TimeZoneRules.contains(schedule.timeZoneId)) { + continue; + } + final candidates = offsetLookup( + schedule.scheduleTime, + schedule.timeZoneId, + budget, + ); + final offset = + schedule.occurrenceOffsetSeconds ?? + (candidates.length == 1 ? candidates.single : null); + if (offset == null) continue; + final instant = CivilDateTime.fromFields( + schedule.scheduleTime, + ).atOffset(offset); + if (instant.isBefore(nowUtc)) continue; + boundary(instant); + boundary(instant.subtract(_leadFor(schedule))); + } + } + + bool hasCurrentTimeAuthority(DateTime now) { + final rules = _authorityRules; + if (rules == null) { + return true; // Export/owned snapshot has no preview claim. + } + final utc = now.toUtc(); + return !utc.isBefore(nowUtc) && + (_authorityBefore == null || utc.isBefore(_authorityBefore!)) && + TimeZoneRules.loadedIdentity == rules; + } + + BackupRestorePreview get preview { + if (!_validated) { + throw StateError('Provisional content cannot be previewed'); + } + return BackupRestorePreview( + cutoff: metadata.cutoff, + cutoffLiteral: metadata.cutoffLiteral, + sourceAppVersion: metadata.sourceAppVersion, + sourcePlatform: metadata.sourcePlatform, + scheduleCount: _scheduleCount, + templateCount: _templateCount, + defaultPreparationStepCount: _defaultCount, + timezoneChangeCount: timezoneChangeCount, + uncertainHistoryCount: uncertainHistoryCount, + uncertainHistoryExamples: List.unmodifiable(_uncertainExamples), + ); + } + + Future timeZoneImpacts({String? cursor}) async { + if (!_validated) throw StateError('No provisional preview'); + final after = cursor == null ? 0 : int.tryParse(cursor); + if (after == null || after < 0) { + throw ArgumentError('Invalid preview cursor'); + } + final rows = store.impactPage(after); + final items = []; + for (final row in rows.take(20)) { + items.add( + BackupTimeZoneImpact( + name: row['name'] as String, + civil: BackupValueValidation.date( + row['civil'] as String, + civilTime: true, + ), + zone: row['zone'] as String, + previousOffset: row['offset'] == null + ? null + : int.parse(row['offset'] as String), + newOffset: int.parse(row['detail'] as String), + ), + ); + } + return BackupTimeZoneImpactPage( + items, + nextCursor: rows.length > 20 ? '${rows[19]['node']}' : null, + ); + } + + Future _validate() async { + final header = store.fields(root, { + 'formatVersion', + 'cutoff', + 'sourceAppVersion', + 'sourcePlatform', + 'dataRevision', + }); + final version = BackupLimits.integer(header['formatVersion']); + if (version != 1 && version != 2) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); + } + header['profile'] = store + .fields(store.requiredChild(root, 'profile', 'object'), { + 'spareTimeMinutes', + 'note', + 'isOnboardingCompleted', + 'eligibleOutcomeCount', + 'onTimeOutcomeCount', + }); + header['preferences'] = store.fields( + store.requiredChild(root, 'preferences', 'object'), + {'alarmsEnabled', 'alarmOffsetMinutes', 'detailedNotificationContent'}, + ); + // Header validation shares the historical codec; no untrusted collections + // are copied into this small object, nor is it a whole-backup adapter. + metadata = BackupContent.fromJson({ + ...header, + 'formatVersion': 1, + 'schedules': [], + 'defaultPreparation': [], + 'schedulePreparations': {}, + 'templates': [], + }); + budget.admitValidated(); // profile + budget.admitValidated(); // preferences + _default = store.requiredChild(root, 'defaultPreparation', 'array'); + final defaultPreparation = _preparation( + _default, + 'userStep', + localProfileId, + admit: true, + ); + _defaultCount = defaultPreparation.preparationStepList.length; + _durationSum(defaultPreparation); + + final schedules = store.requiredChild(root, 'schedules', 'array'); + for (final node in store.children(schedules)) { + budget.admitValidated(schedule: true); + final id = node['id'] as int; + final schedule = _schedule(id); + store.addRecord( + 'schedule', + schedule.id, + id, + reference: schedule.recurringSegmentId, + slot: schedule.recurringSlotKey, + ordinal: schedule.recurringOrdinal, + owner: schedule.preparationDefinitionId, + ); + final priorPlace = store.record('place', schedule.place.id); + if (priorPlace == null) { + budget.admitValidated(); + store.addRecord( + 'place', + schedule.place.id, + id, + detail: schedule.place.placeName, + ); + } else if (priorPlace['detail'] != schedule.place.placeName) { + BackupLimits.invalid(); + } + final historical = _validateTime(schedule, id); + if (schedule.recurringSegmentId != null && + schedule.recurringSlotKey != null && + schedule.recurringOrdinal != null) { + store.addRecord( + 'occurrence', + 'schedule:${schedule.id}', + id, + reference: schedule.recurringSegmentId, + slot: _civil(schedule.recurringSlotKey).toIso8601String(), + ordinal: schedule.recurringOrdinal, + owner: 'schedule', + detail: historical ? 'past' : 'future', + ); + } + _scheduleCount++; + if (_scheduleCount % 64 == 0) await Future.delayed(Duration.zero); + } + + final preparations = store.requiredChild( + root, + 'schedulePreparations', + 'object', + ); + for (final node in store.children(preparations)) { + final owner = node['key'] as String; + _identifier(owner); + if (store.record('schedule', owner) == null) BackupLimits.invalid(); + final id = node['id'] as int; + if (node['kind'] != 'array') BackupLimits.invalid(); + final preparation = _preparation(id, 'scheduleStep', owner, admit: true); + store.addRecord( + 'schedulePreparation', + owner, + id, + detail: _durationSum(preparation).toString(), + ); + } + final templates = store.requiredChild(root, 'templates', 'array'); + for (final node in store.children(templates)) { + budget.admitValidated(); + final id = node['id'] as int; + final template = _template(id, admit: true); + store.addRecord('template', template.id, id); + _templateCount++; + if (_templateCount % 64 == 0) await Future.delayed(Duration.zero); + } + if (version == 2) { + await _validateRecurring( + store.requiredChild(root, 'recurring', 'object'), + ); + } + await _validateScheduleRelations(); + await _validateOrdinals(); + } + + static const _scheduleFields = { + 'id', + 'name', + 'civilTime', + 'timeZoneId', + 'occurrenceOffsetSeconds', + 'moveTimeMinutes', + 'isChanged', + 'spareTimeMinutes', + 'note', + 'latenessTime', + 'doneStatus', + 'finishedAt', + 'startedAt', + 'preparationFrozen', + 'preparationMode', + 'preparationTemplateId', + 'preparationTemplateName', + 'preparationTemplateDeleted', + 'scoreContributionRecorded', + 'recurringSegmentId', + 'recurringSlotKey', + 'recurringOrdinal', + 'recurringOverrides', + 'preparationDefinitionId', + }; + ScheduleEntity _schedule(int id) { + final fields = store.fields(id, _scheduleFields); + fields['place'] = store.fields(store.requiredChild(id, 'place', 'object'), { + 'id', + 'name', + }); + if (_collectTimeIssues) { + for (final field in ['startedAt', 'finishedAt']) { + final value = fields[field]; + if (value != null) { + fields[field] = _reviewableInstant( + id, + field, + value, + ).toIso8601String(); + } + } + } + try { + return BackupContent.decodeScheduleStructure(fields); + } on BackupProcessingFailure { + rethrow; + } on FormatException { + rethrow; + } on ArgumentError { + BackupLimits.invalid(); + } + } + + PreparationEntity _preparation( + int id, + String kind, + String owner, { + bool admit = false, + }) { + final steps = >[]; + for (final node in store.children(id)) { + if (steps.length == BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + if (admit) budget.admitValidated(); + final nodeId = node['id'] as int; + final fields = store.fields(nodeId, {'id', 'name', 'minutes', 'nextId'}); + _identifier(fields['id']); + _name(fields['name']); + if (fields['nextId'] != null) _identifier(fields['nextId']); + BackupLimits.minutes(fields['minutes']); + if (admit) { + store.addRecord( + kind, + fields['id'] as String, + nodeId, + owner: owner, + reference: fields['nextId'] as String?, + ); + } + steps.add(fields); + } + // At most 1,000 steps, each with bounded ID/ref and <=30-character name. + // The original graph is checked before ordered or any DAO can repair it. + budget.visit(steps.length * 2); + return BackupContent.decodePreparation(steps); + } + + PreparationTemplateEntity _template(int id, {bool admit = false}) { + final fields = store.fields(id, {'id', 'name', 'createdAt', 'updatedAt'}); + final identity = _identifier(fields['id']); + _name(fields['name']); + return PreparationTemplateEntity( + id: identity, + name: fields['name'] as String, + createdAt: _collectTimeIssues + ? _reviewableInstant(id, 'createdAt', fields['createdAt']) + : _instant(fields['createdAt']), + updatedAt: _collectTimeIssues + ? _reviewableInstant(id, 'updatedAt', fields['updatedAt']) + : _instant(fields['updatedAt']), + preparation: _preparation( + store.requiredChild(id, 'preparation', 'array'), + 'templateStep', + identity, + admit: admit, + ), + ); + } + + DateTime _instant(Object? value) { + if (value is! String) BackupLimits.invalid(); + final instant = BackupValueValidation.instant(value); + if (instant.microsecondsSinceEpoch % Duration.microsecondsPerSecond != 0) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); + } + return instant; + } + + DateTime _reviewableInstant(int parent, String field, Object? value) { + if (value is! String) BackupLimits.invalid(); + final civil = BackupValueValidation.date(value, civilTime: true); + if (civil.microsecondsSinceEpoch % Duration.microsecondsPerSecond != 0) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); + } + if (BackupValueValidation.explicitOffset(value)) return _instant(value); + _timeIssue(parent, field, BackupTimeIssueReason.missingInstantOffset); + // A structural-only carrier preserves the presence of a started/frozen + // fact while checking unrelated ownership/graphs. _validated remains false + // while this issue exists, so it can never be materialized as an instant. + return civil; + } + + void _timeIssue(int parent, String field, BackupTimeIssueReason reason) { + final id = '$parent:$field'; + if (store.record('timeIssue', id) != null) return; + final node = store.child(parent, field); + if (node == null) BackupLimits.invalid(); + store.addRecord( + 'timeIssue', + id, + node, + owner: '$parent', + reference: field, + detail: reason.name, + ); + timeIssueCount++; + } + + String _identifier(Object? value) { + if (value is! String || value.isEmpty) BackupLimits.invalid(); + BackupLimits.string(value, identifier: true); + return value; + } + + void _name(Object? value) { + if (value is! String || value.isEmpty || value.length > 30) { + BackupLimits.invalid(); + } + } + + int _durationSum(PreparationEntity value) { + var minutes = 0; + for (final step in value.preparationStepList) { + final addition = BackupLimits.minutes(step.preparationTime.inMinutes); + if (addition > 0x7fffffffffffffff ~/ 60000000 - minutes) { + BackupLimits.invalid(); + } + minutes += addition; + } + return minutes; + } + + void _uncertain( + int node, + DateTime civil, + String zone, + String? name, { + BackupHistoryUncertaintyReason reason = + BackupHistoryUncertaintyReason.historicalProvenance, + }) { + if (store.record('uncertainty', '$node') != null) return; + store.addRecord('uncertainty', '$node', node); + uncertainHistoryCount++; + if (_uncertainExamples.length < 5) { + _uncertainExamples.add( + BackupHistoryUncertainty( + name: name, + civil: civil, + zone: zone, + reason: reason, + ), + ); + } + } + + bool _validateTime(ScheduleEntity schedule, int node) { + // Structure has been admitted separately. Unknown historical identifiers + // are preserved; an unknown future never acquires a fallback zone. + final knownZone = TimeZoneRules.contains(schedule.timeZoneId); + final civil = schedule.scheduleTime; + final offset = schedule.occurrenceOffsetSeconds; + final explicitInstant = offset == null + ? null + : civil.subtract(Duration(seconds: offset)); + if (explicitInstant != null && + (explicitInstant.year < 1 || explicitInstant.year > 9999)) { + BackupLimits.invalid(); + } + final historical = + RecurrenceReferencePolicy.hasProtectedFacts(schedule) || + (explicitInstant?.isBefore(nowUtc) ?? false) || + (offset == null && + civil.add(const Duration(hours: 24)).isBefore(nowUtc)); + if (historical) { + if (offset == null || !knownZone) { + _uncertain( + node, + civil, + schedule.timeZoneId, + schedule.scheduleName, + reason: knownZone + ? BackupHistoryUncertaintyReason.historicalProvenance + : schedule.startedAt != null && + schedule.preparationFrozen && + schedule.doneStatus == ScheduleDoneStatus.notEnded + ? BackupHistoryUncertaintyReason.protectedStartZone + : BackupHistoryUncertaintyReason.unavailableHistoricalZone, + ); + } + return true; // Current tzdb cannot disprove a preserved historical occurrence. + } + if (!knownZone) { + if (_collectTimeIssues) { + _timeIssue(node, 'civilTime', BackupTimeIssueReason.unknownZone); + return false; + } + throw const BackupProcessingFailure( + BackupFailureKind.timeZoneChoiceRequired, + ); + } + final candidates = offsetLookup(civil, schedule.timeZoneId, budget); + if (offset == null && + candidates.isNotEmpty && + candidates.every( + (candidate) => + civil.subtract(Duration(seconds: candidate)).isBefore(nowUtc), + )) { + _uncertain(node, civil, schedule.timeZoneId, schedule.scheduleName); + return true; // Preserve original null; this is an interpretation, not proof. + } + if (candidates.isEmpty || + (candidates.length > 1 && !candidates.contains(offset))) { + if (_collectTimeIssues) { + _timeIssue( + node, + 'civilTime', + candidates.isEmpty + ? BackupTimeIssueReason.nonexistentCivil + : BackupTimeIssueReason.repeatedCivil, + ); + return false; + } + throw const BackupProcessingFailure( + BackupFailureKind.timeZoneChoiceRequired, + ); + } + if (offset != null && + candidates.length == 1 && + candidates.single != offset) { + if (_collectTimeIssues) { + _timeIssue(node, 'civilTime', BackupTimeIssueReason.changedOffset); + } else { + throw const BackupProcessingFailure( + BackupFailureKind.timeZoneChoiceRequired, + ); + } + } + return false; + } + + Future _validateRecurring(int root) async { + // Implemented as per-record scalar decode plus disk-indexed ownership below; + // no full RecurringBackupData/maps or expansion of an infinite series. + for (final kind in ['definitions', 'steps', 'segments', 'exclusions']) { + final array = store.requiredChild(root, kind, 'array'); + var count = 0; + for (final node in store.children(array)) { + budget.admitValidated(); + final id = node['id'] as int; + switch (kind) { + case 'definitions': + final value = _definition(id); + store.addRecord( + 'definition', + value.id, + id, + owner: value.ownerId, + detail: value.scope, + ); + case 'steps': + final value = _definitionStep(id); + store.addRecord( + 'definitionStep', + value.id, + id, + owner: value.definitionId, + position: value.position, + ); + case 'segments': + final value = _segment(id); + store.addRecord( + 'segment', + value.id, + id, + owner: value.seriesId, + reference: value.preparationId, + ); + await _spec(value); + case 'exclusions': + final value = _exclusion(id); + store.addRecord( + 'exclusion', + '$id', + id, + reference: value.segmentId, + slot: _civil(value.slotKey).toIso8601String(), + ordinal: value.ordinal, + ); + store.addRecord( + 'occurrence', + 'exclusion:$id', + id, + reference: value.segmentId, + slot: _civil(value.slotKey).toIso8601String(), + ordinal: value.ordinal, + owner: 'exclusion', + detail: + _civil( + value.slotKey, + ).add(const Duration(hours: 24)).isBefore(nowUtc) + ? 'past' + : 'future', + ); + } + if (++count % 64 == 0) await Future.delayed(Duration.zero); + } + } + for (final definition in store.records('definition')) { + final positions = {}; + var totalMinutes = 0; + for (final step in store.records( + 'definitionStep', + owner: definition['identity'] as String, + )) { + if (positions.length == BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + if (!positions.add(step['position'] as int)) BackupLimits.invalid(); + final minutes = _definitionStep(step['node'] as int).minutes; + if (minutes > 0x7fffffffffffffff ~/ 60000000 - totalMinutes) { + BackupLimits.invalid(); + } + totalMinutes += minutes; + } + if (positions.isEmpty || + positions.any((p) => p < 0 || p >= positions.length)) { + BackupLimits.invalid(); + } + store.addRecord( + 'definitionSum', + definition['identity'] as String, + definition['node'] as int, + detail: '$totalMinutes', + ); + } + for (final step in store.records('definitionStep')) { + if (store.record('definition', step['owner'] as String) == null) { + BackupLimits.invalid(); + } + } + for (final segment in store.records('segment')) { + final definition = store.record( + 'definition', + segment['reference'] as String, + ); + if (definition == null || + definition['detail'] != 'recurring' || + definition['owner'] != segment['owner']) { + BackupLimits.invalid(); + } + } + for (final exclusion in store.records('exclusion')) { + final segment = store.record('segment', exclusion['reference'] as String); + if (segment == null) BackupLimits.invalid(); + await _validateSlot( + _segment(segment['node'] as int), + exclusion['slot'] as String, + exclusion['ordinal'] as int, + retainedReference: true, + ); + } + } + + PreparationDefinition _definition(int node) { + final data = store.fields(node, { + 'id', + 'ownerId', + 'scope', + 'name', + 'createdAt', + }); + _identifier(data['id']); + _identifier(data['ownerId']); + if (!{'recurring', 'occurrence', 'template'}.contains(data['scope'])) { + BackupLimits.invalid(); + } + if (data['name'] is! String) BackupLimits.invalid(); + _dbInstant(data['createdAt']); + try { + return PreparationDefinition.fromJson(data); + } catch (_) { + BackupLimits.invalid(); + } + } + + PreparationDefinitionStep _definitionStep(int node) { + final data = store.fields(node, { + 'id', + 'definitionId', + 'name', + 'minutes', + 'position', + }); + _identifier(data['id']); + _identifier(data['definitionId']); + _name(data['name']); + BackupLimits.integer(data['minutes'], maximum: 1440); + BackupLimits.integer(data['position']); + try { + return PreparationDefinitionStep.fromJson(data); + } catch (_) { + BackupLimits.invalid(); + } + } + + RecurringScheduleSegment _segment(int node) { + final data = store.fields(node, { + 'id', + 'seriesId', + 'ruleJson', + 'scheduleJson', + 'preparationId', + 'fromSlot', + 'beforeSlot', + 'createdAt', + 'preparationNotBefore', + }); + _identifier(data['id']); + _identifier(data['seriesId']); + _identifier(data['preparationId']); + if (data['ruleJson'] is! String || data['scheduleJson'] is! String) { + BackupLimits.invalid(); + } + _civil(data['fromSlot']); + if (data['beforeSlot'] != null && + _civil(data['beforeSlot']).isBefore(_civil(data['fromSlot']))) { + BackupLimits.invalid(); + } + _dbInstant(data['createdAt']); + if (data['preparationNotBefore'] != null) { + _dbInstant(data['preparationNotBefore']); + } + try { + return RecurringScheduleSegment.fromJson(data); + } catch (_) { + BackupLimits.invalid(); + } + } + + RecurringScheduleExclusion _exclusion(int node) { + final data = store.fields(node, {'segmentId', 'slotKey', 'ordinal'}); + _identifier(data['segmentId']); + _civil(data['slotKey']); + BackupLimits.integer(data['ordinal'], minimum: 1); + try { + return RecurringScheduleExclusion.fromJson(data); + } catch (_) { + BackupLimits.invalid(); + } + } + + DateTime _civil(Object? value) { + if (value is! String) BackupLimits.invalid(); + return BackupValueValidation.date(value, civilTime: true); + } + + void _dbInstant(Object? value) { + final millis = BackupLimits.integer( + value, + minimum: -62135596800000, + maximum: 253402300799000, + ); + if (millis % 1000 != 0) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); + } + } + + Future _validateSlot( + RecurringScheduleSegment segment, + String key, + int ordinal, { + bool retainedReference = false, + }) async { + budget.visit(); + final slot = _civil(key); + if (slot.isBefore(_civil(segment.fromSlot)) || + (!retainedReference && + segment.beforeSlot != null && + !slot.isBefore(_civil(segment.beforeSlot)))) { + BackupLimits.invalid(); + } + BackupLimits.integer(ordinal, minimum: 1); + final rule = (await _spec(segment)).rule; + if (rule.count != null && ordinal > rule.count!) BackupLimits.invalid(); + if (slot.isBefore(rule.start) || + (rule.until != null && + RecurrenceRule.civilDate(slot).isAfter(rule.until!))) { + BackupLimits.invalid(); + } + if (slot.hour != rule.start.hour || + slot.minute != rule.start.minute || + slot.second != rule.start.second || + slot.millisecond != rule.start.millisecond || + slot.microsecond != rule.start.microsecond) { + BackupLimits.invalid(); + } + final days = RecurrenceRule.civilDate( + slot, + ).difference(RecurrenceRule.civilDate(rule.start)).inDays; + switch (rule.frequency) { + case RecurrenceFrequency.daily: + if (days % rule.interval != 0) BackupLimits.invalid(); + case RecurrenceFrequency.weekly: + final startWeek = RecurrenceRule.civilDate( + rule.start, + ).subtract(Duration(days: rule.start.weekday - 1)); + if (!rule.weekdays.contains(slot.weekday) || + RecurrenceRule.civilDate(slot).difference(startWeek).inDays ~/ + 7 % + rule.interval != + 0) { + BackupLimits.invalid(); + } + case RecurrenceFrequency.monthly: + final months = + (slot.year - rule.start.year) * 12 + slot.month - rule.start.month; + if (months % rule.interval != 0) BackupLimits.invalid(); + final last = DateTime.utc(slot.year, slot.month + 1, 0).day; + switch (rule.monthly) { + case MonthlyRecurrence.dayOfMonth: + if (slot.day != rule.monthDay) BackupLimits.invalid(); + case MonthlyRecurrence.lastDay: + if (slot.day != last) BackupLimits.invalid(); + case MonthlyRecurrence.nthWeekday: + if (slot.weekday != rule.monthWeekday || + (rule.ordinal == -1 + ? slot.day + 7 <= last + : (slot.day - 1) ~/ 7 + 1 != rule.ordinal)) { + BackupLimits.invalid(); + } + } + } + } + + Future<_SegmentSpec> _spec(RecurringScheduleSegment segment) async { + budget.visit(); + final cached = _specCache.remove(segment.id); + if (cached != null) { + _specCache[segment.id] = cached; + return cached; + } + final ruleData = await _innerObject(segment.ruleJson); + final schedule = await _innerObject(segment.scheduleJson); + BackupValueValidation.zoneIdentifier(_identifier(ruleData['zone'])); + if (schedule['zone'] != ruleData['zone']) BackupLimits.invalid(); + final start = _civil(ruleData['start']); + final weekdays = ruleData['weekdays']; + if (weekdays is! List || + weekdays.length > 7 || + weekdays.any((d) => d is! int)) { + BackupLimits.invalid(); + } + if (weekdays.toSet().length != weekdays.length) BackupLimits.invalid(); + final RecurrenceRule rule; + try { + rule = RecurrenceRule( + frequency: RecurrenceFrequency.values.byName( + ruleData['frequency'] as String, + ), + start: start, + timeZoneId: ruleData['zone'] as String, + interval: BackupLimits.integer(ruleData['interval'], minimum: 1), + weekdays: weekdays.cast().toSet(), + monthly: MonthlyRecurrence.values.byName(ruleData['monthly'] as String), + monthDay: BackupLimits.integer( + ruleData['monthDay'], + minimum: 1, + maximum: 31, + ), + ordinal: BackupLimits.integer( + ruleData['ordinal'], + minimum: -1, + maximum: 5, + ), + monthWeekday: BackupLimits.integer( + ruleData['monthWeekday'], + minimum: 1, + maximum: 7, + ), + count: ruleData['count'] == null + ? null + : BackupLimits.integer(ruleData['count'], minimum: 1), + until: ruleData['until'] == null ? null : _civil(ruleData['until']), + repeatedTime: ruleData['repeatedTime'] == null + ? null + : RepeatedCivilTime.values.byName( + ruleData['repeatedTime'] as String, + ), + ); + } on BackupProcessingFailure { + rethrow; + } catch (_) { + BackupLimits.invalid(); + } + if (_civil(segment.fromSlot).isBefore(rule.start)) BackupLimits.invalid(); + _identifier(schedule['id']); + _identifier(schedule['placeId']); + _name(schedule['place']); + if (schedule['name'] is! String || + (schedule['name'] as String).trim().isEmpty || + schedule['note'] is! String) { + BackupLimits.invalid(); + } + BackupLimits.string(schedule['note'] as String, note: true); + final base = ScheduleEntity( + id: schedule['id'] as String, + place: PlaceEntity( + id: schedule['placeId'] as String, + placeName: schedule['place'] as String, + ), + scheduleName: schedule['name'] as String, + scheduleTime: _civil(schedule['time']), + timeZoneId: schedule['zone'] as String, + occurrenceOffsetSeconds: schedule['offset'] == null + ? null + : BackupLimits.integer( + schedule['offset'], + minimum: -86400, + maximum: 86400, + ), + moveTime: Duration(minutes: BackupLimits.minutes(schedule['move'])), + scheduleSpareTime: schedule['spare'] == null + ? null + : Duration(minutes: BackupLimits.minutes(schedule['spare'])), + scheduleNote: schedule['note'] as String, + isChanged: false, + isStarted: false, + ); + final spec = _SegmentSpec(rule, base); + if (!TimeZoneRules.contains(rule.timeZoneId) && + !RecurrenceReferencePolicy.hasNoFutureGeneration( + rule: rule, + fromSlot: _civil(segment.fromSlot), + beforeSlot: segment.beforeSlot == null + ? null + : _civil(segment.beforeSlot), + nowUtc: nowUtc, + )) { + if (_collectTimeIssues) { + final recordNode = store.record('segment', segment.id)?['node'] as int?; + // The segment's record is installed by the caller before this lookup. + if (recordNode == null) BackupLimits.invalid(); + _timeIssue(recordNode, 'ruleJson', BackupTimeIssueReason.unknownZone); + } else { + throw const BackupProcessingFailure( + BackupFailureKind.timeZoneChoiceRequired, + ); + } + } + if (_specCache.length == 16) _specCache.remove(_specCache.keys.first); + _specCache[segment.id] = spec; + return spec; + } + + Future> _innerObject(String value) async { + // ruleJson/scheduleJson are themselves bounded to 64KiB scalar input. This + // small record decoder shares strict duplicate/depth/token accounting. + BackupLimits.string(value); + final sink = _SmallObjectSink(); + final root = await BackupJsonReader( + sink, + budget, + ).read(Stream.value(utf8.encode(value))); + final result = sink.nodes[root]; + if (result is! Map) BackupLimits.invalid(); + return result; + } + + Future _validateScheduleRelations() async { + var count = 0; + for (final record in store.records('schedule')) { + final schedule = _schedule(record['node'] as int); + final links = [ + schedule.recurringSegmentId, + schedule.recurringSlotKey, + schedule.recurringOrdinal, + ]; + if (links.any((v) => v != null) && links.any((v) => v == null)) { + BackupLimits.invalid(); + } + if (schedule.preparationDefinitionId != null && + store.record('definition', schedule.preparationDefinitionId!) == + null) { + BackupLimits.invalid(); + } + if (schedule.recurringSegmentId != null) { + final segment = store.record('segment', schedule.recurringSegmentId!); + if (segment == null || schedule.preparationDefinitionId == null) { + BackupLimits.invalid(); + } + await _validateSlot( + _segment(segment['node'] as int), + schedule.recurringSlotKey!, + schedule.recurringOrdinal!, + retainedReference: + RecurrenceReferencePolicy.hasProtectedFacts(schedule) || + store.record( + 'occurrence', + 'schedule:${schedule.id}', + )?['detail'] == + 'past', + ); + if (store.recordAtSlot( + 'exclusion', + schedule.recurringSegmentId!, + _civil(schedule.recurringSlotKey!).toIso8601String(), + ) != + null) { + BackupLimits.invalid(); + } + final source = await _spec(_segment(segment['node'] as int)); + final overrides = schedule.recurringOverrides + .split(',') + .where((s) => s.isNotEmpty) + .toSet(); + final definition = store.record( + 'definition', + schedule.preparationDefinitionId!, + ); + if (definition == null) BackupLimits.invalid(); + if (!overrides.contains('preparation') && + schedule.preparationDefinitionId != segment['reference']) { + BackupLimits.invalid(); + } + if (overrides.contains('preparation') && + (definition['detail'] != 'occurrence' || + definition['owner'] != schedule.id)) { + BackupLimits.invalid(); + } + if (!overrides.contains('time') && + (schedule.scheduleTime != _civil(schedule.recurringSlotKey) || + schedule.timeZoneId != source.rule.timeZoneId)) { + BackupLimits.invalid(); + } + if (!overrides.contains('name') && + schedule.scheduleName != source.schedule.scheduleName) { + BackupLimits.invalid(); + } + if (!overrides.contains('place') && + schedule.place != source.schedule.place) { + BackupLimits.invalid(); + } + if (!overrides.contains('move') && + schedule.moveTime != source.schedule.moveTime) { + BackupLimits.invalid(); + } + if (!overrides.contains('spare') && + schedule.scheduleSpareTime != source.schedule.scheduleSpareTime) { + BackupLimits.invalid(); + } + if (!overrides.contains('note') && + schedule.scheduleNote != source.schedule.scheduleNote) { + BackupLimits.invalid(); + } + } + const allowed = { + 'name', + 'time', + 'place', + 'move', + 'spare', + 'note', + 'preparation', + }; + if (schedule.recurringOverrides + .split(',') + .where((s) => s.isNotEmpty) + .any((s) => !allowed.contains(s))) { + BackupLimits.invalid(); + } + final preparation = store.record('schedulePreparation', schedule.id); + final effectiveDefinition = schedule.preparationDefinitionId == null + ? null + : store.record('definitionSum', schedule.preparationDefinitionId!); + final prepMinutes = effectiveDefinition != null + ? int.parse(effectiveDefinition['detail'] as String) + : preparation == null + ? 0 + : int.parse(preparation['detail'] as String); + var total = prepMinutes; + for (final value in [ + schedule.moveTime.inMinutes, + (schedule.scheduleSpareTime ?? Duration.zero).inMinutes, + ]) { + if (value > 0x7fffffffffffffff ~/ 60000000 - total) { + BackupLimits.invalid(); + } + total += value; + } + final civilMicros = schedule.scheduleTime.microsecondsSinceEpoch; + final lower = DateTime.utc(1).microsecondsSinceEpoch; + final upper = DateTime.utc(10000).microsecondsSinceEpoch - 1; + final leadMicros = total * 60000000; // checked multiplication above + if (leadMicros > civilMicros - lower) BackupLimits.invalid(); + final change = store.record('timezoneChange', schedule.id); + final offset = change == null + ? schedule.occurrenceOffsetSeconds + : int.parse(change['detail'] as String); + if (offset != null) { + final instantMicros = civilMicros - offset * 1000000; + if (instantMicros < lower || + instantMicros > upper || + leadMicros > instantMicros - lower) { + BackupLimits.invalid(); + } + } + if (++count % 64 == 0) await Future.delayed(Duration.zero); + } + } + + Future _validateOrdinals() async { + for (final record in store.records('segment')) { + final segment = _segment(record['node'] as int); + final spec = await _spec(segment); + final knownZone = TimeZoneRules.contains(spec.rule.timeZoneId); + if (!knownZone) { + _uncertain( + record['node'] as int, + spec.rule.start, + spec.rule.timeZoneId, + spec.schedule.scheduleName, + reason: BackupHistoryUncertaintyReason.historicalOrdinal, + ); + } + final references = store.occurrences(segment.id).iterator; + if (!references.moveNext()) continue; + var expected = references.current; + final last = store.lastOccurrence(segment.id)!; + final definitionSum = store.record( + 'definitionSum', + segment.preparationId, + ); + if (definitionSum == null) BackupLimits.invalid(); + var leadMinutes = int.parse(definitionSum['detail'] as String); + for (final amount in [ + spec.schedule.moveTime.inMinutes, + spec.schedule.scheduleSpareTime?.inMinutes ?? 0, + ]) { + if (amount > 0x7fffffffffffffff ~/ 60000000 - leadMinutes) { + BackupLimits.invalid(); + } + leadMinutes += amount; + } + var previous = 0; + var visited = 0; + await for (final candidate in scanBackupRule( + rule: spec.rule, + through: _civil(last['slot']), + budget: budget, + leadTime: Duration(minutes: leadMinutes), + preparationNotBefore: segment.preparationNotBefore, + offsetLookup: knownZone + ? offsetLookup + : (civil, zone, budget) { + budget.visit(); + return const []; + }, + )) { + final wanted = _civil(expected['slot']); + if (candidate.civil.isBefore(wanted)) continue; + if (candidate.civil != wanted) BackupLimits.invalid(); + final ordinal = expected['ordinal'] as int; + if (ordinal <= previous || ordinal > candidate.calendarOrdinal) { + BackupLimits.invalid(); + } + previous = ordinal; + if (!candidate.eligible || ordinal != candidate.currentOrdinal) { + final retainedTail = RecurrenceReferencePolicy.isClosedTail( + expected['slot'] as String, + segment.beforeSlot, + ); + // Relation validation has already rejected unprotected future rows. + // A retained tombstone/history cannot become executable by matching + // the current timezone database's ordinal. + if (expected['detail'] != 'past' && !retainedTail) { + if (_collectTimeIssues) { + _timeIssue( + record['node'] as int, + 'ruleJson', + BackupTimeIssueReason.recurrenceOrder, + ); + } else { + throw const BackupProcessingFailure( + BackupFailureKind.timeZoneChoiceRequired, + ); + } + } + store.addRecord( + 'uncertainOrdinal', + expected['identity'] as String, + expected['node'] as int, + ); + _uncertain( + expected['node'] as int, + wanted, + spec.rule.timeZoneId, + expected['owner'] == 'schedule' && _uncertainExamples.length < 5 + ? _schedule(expected['node'] as int).scheduleName + : null, + ); + } + if (++visited % 64 == 0) await Future.delayed(Duration.zero); + if (!references.moveNext()) { + expected = const {}; + break; + } + expected = references.current; + } + if (expected.isNotEmpty) BackupLimits.invalid(); + } + } + + Future materialize( + AppDatabase database, { + required bool pendingCleanup, + }) => database.transaction( + () => _materialize(database, pendingCleanup: pendingCleanup), + ); + + Future _materialize( + AppDatabase database, { + required bool pendingCleanup, + }) async { + if (!_validated) { + throw StateError('Unauthenticated content cannot be materialized'); + } + await metadata.writeTo(database, pendingCleanup: pendingCleanup); + await database.preparationUserDao.createPreparationUser( + _preparation(_default, 'userStep', localProfileId), + localProfileId, + ); + for (final row in store.records('definition')) { + await database + .into(database.preparationDefinitions) + .insert(_definition(row['node'] as int).toCompanion(false)); + } + for (final row in store.records('definitionStep')) { + await database + .into(database.preparationDefinitionSteps) + .insert(_definitionStep(row['node'] as int).toCompanion(false)); + } + for (final row in store.records('segment')) { + await database + .into(database.recurringScheduleSegments) + .insert(_segment(row['node'] as int).toCompanion(false)); + } + for (final row in store.records('exclusion')) { + await database + .into(database.recurringScheduleExclusions) + .insert(_exclusion(row['node'] as int).toCompanion(false)); + } + for (final row in store.records('schedule')) { + final schedule = _schedule(row['node'] as int); + // Backup civil values are strict wall-clock carriers. The runtime DAO's + // local DateTime converter can normalize a foreign zone's valid DST-gap + // reading, so the portable boundary writes the literal SQL column. + final portable = schedule.toScheduleWithPlaceRow(); + await database + .into(database.places) + .insertOnConflictUpdate(portable.place.toCompanion(false)); + final columns = portable.schedule.toCompanion(false).toColumns(false); + for (final field in [ + 'requires_start_confirmation', + 'aggregate_incarnation', + 'aggregate_version', + 'last_mutation_id', + 'last_mutation_digest', + 'last_mutation_version', + ]) { + columns.remove(field); + } + final civil = schedule.scheduleTime.toIso8601String(); + columns['schedule_time'] = Variable( + civil.substring(0, civil.length - 1), + ); + await database + .into(database.schedules) + .insert(RawValuesInsertable(columns)); + final change = store.record('timezoneChange', schedule.id); + if (change != null || + (pendingCleanup && + schedule.doneStatus == ScheduleDoneStatus.notEnded)) { + await (database.update( + database.schedules, + )..where((t) => t.id.equals(schedule.id))).write( + SchedulesCompanion( + occurrenceOffsetSeconds: change == null + ? const Value.absent() + : Value(int.parse(change['detail'] as String)), + requiresStartConfirmation: Value( + pendingCleanup && + schedule.doneStatus == ScheduleDoneStatus.notEnded, + ), + ), + ); + } + final preparation = store.record('schedulePreparation', schedule.id); + if (preparation != null) { + await database.preparationScheduleDao.createPreparationSchedule( + _preparation(preparation['node'] as int, 'scheduleStep', schedule.id), + schedule.id, + ); + } + } + for (final row in store.records('template')) { + final template = _template(row['node'] as int); + await database.preparationTemplateDao.restore( + id: template.id, + name: template.name, + preparation: template.preparation, + createdAt: template.createdAt, + updatedAt: template.updatedAt, + ); + } + } + + Future validateReadBack( + AppDatabase database, { + required bool pendingCleanup, + }) async { + if (!_validated) throw StateError('Cannot verify provisional content'); + final foreignKeys = await database + .customSelect('SELECT * FROM pragma_foreign_key_check LIMIT 1') + .get(); + final check = await database.customSelect('PRAGMA quick_check(1)').get(); + if (foreignKeys.isNotEmpty || check.single.data.values.single != 'ok') { + BackupLimits.invalid(); + } + final expectedCounts = { + 'users': 1, + 'places': store.recordCount('place'), + 'schedules': _scheduleCount, + 'preparation_users': store.recordCount('userStep'), + 'preparation_schedules': store.recordCount('scheduleStep'), + 'preparation_templates': _templateCount, + 'preparation_template_steps': store.recordCount('templateStep'), + 'preparation_definitions': store.recordCount('definition'), + 'preparation_definition_steps': store.recordCount('definitionStep'), + 'recurring_schedule_segments': store.recordCount('segment'), + 'recurring_schedule_exclusions': store.recordCount('exclusion'), + }; + for (final entry in expectedCounts.entries) { + budget.visit(entry.value); + final actual = + (await database + .customSelect('SELECT count(*) AS n FROM ${entry.key}') + .getSingle()) + .read('n'); + if (actual != entry.value) BackupLimits.invalid(); + } + final user = await database.select(database.users).getSingle(); + final profile = metadata.profile.valueOrNull!; + if (user.id != localProfileId || + user.spareTime != profile.spareTime.inMinutes || + user.note != profile.note || + user.isOnboardingCompleted != profile.isOnboardingCompleted || + user.eligibleOutcomeCount != profile.eligibleOutcomeCount || + user.onTimeOutcomeCount != profile.onTimeOutcomeCount || + user.alarmsEnabled != metadata.alarmsEnabled || + user.alarmOffsetMinutes != metadata.alarmOffsetMinutes || + user.detailedNotificationContent != + metadata.detailedNotificationContent || + user.dataRevision != metadata.dataRevision + 1 || + user.restoreCleanupPending != pendingCleanup || + user.rejectLegacyDelivery != pendingCleanup) { + BackupLimits.invalid(); + } + _same( + BackupContent.encodePreparation( + _preparation(_default, 'userStep', localProfileId), + ), + BackupContent.encodePreparation( + await database.preparationUserDao.getPreparationUsersByUserId( + localProfileId, + ), + ), + ); + for (final record in store.records('schedule')) { + final expected = _schedule(record['node'] as int); + final actualRow = await database.scheduleDao.getScheduleById(expected.id); + final actual = actualRow.toScheduleEntity(); + final expectedJson = BackupContent.encodeSchedule(expected); + final changed = store.record('timezoneChange', expected.id); + if (changed != null) { + expectedJson['occurrenceOffsetSeconds'] = int.parse( + changed['detail'] as String, + ); + } + budget.visit(); // Additional bounded raw civil-column lookup. + final actualJson = BackupContent.encodeSchedule(actual); + actualJson['civilTime'] = + (await database + .customSelect( + 'SELECT schedule_time FROM schedules WHERE id = ?', + variables: [Variable(expected.id)], + ) + .getSingle()) + .read('schedule_time'); + _same(_canonicalSchedule(expectedJson), _canonicalSchedule(actualJson)); + if (actual.isStarted || + actualRow.schedule.requiresStartConfirmation != + (pendingCleanup && + expected.doneStatus == ScheduleDoneStatus.notEnded)) { + BackupLimits.invalid(); + } + final preparation = store.record('schedulePreparation', expected.id); + if (preparation != null) { + _same( + BackupContent.encodePreparation( + _preparation( + preparation['node'] as int, + 'scheduleStep', + expected.id, + ), + ), + BackupContent.encodePreparation( + await database.preparationScheduleDao + .getPreparationSchedulesByScheduleId(expected.id), + ), + ); + } + } + for (final record in store.records('template')) { + final expected = _template(record['node'] as int); + final actual = await database.preparationTemplateDao.getById(expected.id); + if (expected.id != actual.id || + expected.name != actual.name || + !expected.createdAt.isAtSameMomentAs(actual.createdAt) || + !expected.updatedAt.isAtSameMomentAs(actual.updatedAt)) { + BackupLimits.invalid(); + } + _same( + BackupContent.encodePreparation(expected.preparation), + BackupContent.encodePreparation(actual.preparation), + ); + } + for (final record in store.records('definition')) { + final expected = _definition(record['node'] as int); + final actual = await (database.select( + database.preparationDefinitions, + )..where((t) => t.id.equals(expected.id))).getSingle(); + _same(expected.toJson(), actual.toJson()); + } + for (final record in store.records('definitionStep')) { + final expected = _definitionStep(record['node'] as int); + final actual = await (database.select( + database.preparationDefinitionSteps, + )..where((t) => t.id.equals(expected.id))).getSingle(); + _same(expected.toJson(), actual.toJson()); + } + for (final record in store.records('segment')) { + final expected = _segment(record['node'] as int); + final actual = await (database.select( + database.recurringScheduleSegments, + )..where((t) => t.id.equals(expected.id))).getSingle(); + final left = expected.toJson(); + final right = actual.toJson(); + for (final key in [ + 'rootSegmentId', + 'aggregateIncarnation', + 'aggregateVersion', + 'lastMutationId', + 'lastMutationDigest', + 'lastMutationVersion', + ]) { + left.remove(key); + right.remove(key); + } + _same(left, right); + } + for (final record in store.records('exclusion')) { + final expected = _exclusion(record['node'] as int); + final actual = + await (database.select(database.recurringScheduleExclusions)..where( + (t) => + t.segmentId.equals(expected.segmentId) & + t.slotKey.equals(expected.slotKey), + )) + .getSingle(); + _same(expected.toJson(), actual.toJson()); + } + } + + Map _canonicalSchedule(Map value) { + value['civilTime'] = _civil(value['civilTime']).toIso8601String(); + for (final field in ['startedAt', 'finishedAt']) { + if (value[field] != null) { + value[field] = BackupValueValidation.date( + value[field] as String, + ).toUtc().toIso8601String(); + } + } + return value; + } + + void _same(Object? expected, Object? actual) { + budget.visit(); + if (!const DeepCollectionEquality().equals(expected, actual)) { + BackupLimits.invalid(); + } + } + + Future release() => store.release(); +} + +class _SegmentSpec { + const _SegmentSpec(this.rule, this.schedule); + final RecurrenceRule rule; + final ScheduleEntity schedule; +} + +class _SmallObjectSink implements BackupJsonSink { + final nodes = {}; + @override + int writeNode(int? parent, String? key, String kind, Object? scalar) { + final dynamic value = kind == 'object' + ? {} + : kind == 'array' + ? [] + : scalar; + final id = nodes.length + 1; + if (parent != null) { + final target = nodes[parent]; + if (target is Map) { + if (target.containsKey(key)) BackupLimits.invalid(); + target[key!] = value; + } else { + (target as List).add(value); + } + } + nodes[id] = value; + return id; + } +} diff --git a/lib/core/backup/backup_value_validation.dart b/lib/core/backup/backup_value_validation.dart new file mode 100644 index 00000000..84034f88 --- /dev/null +++ b/lib/core/backup/backup_value_validation.dart @@ -0,0 +1,102 @@ +import '../time/time_zone_rules.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +abstract final class BackupValueValidation { + /// Portable syntax/size only. Registry meaning needs a validation-time + /// context and must be checked before any future execution is authorized. + static void zoneIdentifier(String value) { + BackupLimits.string(value, identifier: true); + if (value.isEmpty) BackupLimits.invalid(); + } + + static void namedZone(String value) { + zoneIdentifier(value); + TimeZoneRules.ensureInitialized(); + try { + tz.getLocation(value); + } catch (_) { + BackupLimits.invalid(); + } + } + + static bool explicitOffset(String value) => + RegExp(r'(Z|[+-]\d{2}:?\d{2})$').hasMatch(value); + + static DateTime instant(String value) { + // Validate literal calendar before classifying its missing representation. + date(value, civilTime: true); + if (!explicitOffset(value)) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); + } + final parsed = date(value).toUtc(); + if (parsed.year < 1 || parsed.year > 9999) BackupLimits.invalid(); + if (parsed.microsecondsSinceEpoch % Duration.microsecondsPerSecond != 0) { + throw const BackupProcessingFailure( + BackupFailureKind.unsupportedRepresentation, + ); + } + return parsed; + } + + static DateTime date(String value, {bool civilTime = false}) { + BackupLimits.string(value); + final match = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:?\d{2})?$', + ).firstMatch(value); + if (match == null || match.end != value.length) BackupLimits.invalid(); + final parts = [for (var i = 1; i <= 6; i++) int.parse(match.group(i)!)]; + if (parts[0] < 1 || + parts[1] < 1 || + parts[1] > 12 || + parts[2] < 1 || + parts[2] > 31 || + parts[3] > 23 || + parts[4] > 59 || + parts[5] > 59) { + BackupLimits.invalid(); + } + final civil = DateTime.utc( + parts[0], + parts[1], + parts[2], + parts[3], + parts[4], + parts[5], + ); + if (civil.year != parts[0] || + civil.month != parts[1] || + civil.day != parts[2]) { + BackupLimits.invalid(); + } + final zone = match.group(8); + if (zone != null && zone != 'Z') { + final digits = zone.substring(1).replaceAll(':', ''); + if (int.parse(digits.substring(0, 2)) > 23 || + int.parse(digits.substring(2)) > 59) { + BackupLimits.invalid(); + } + } + if (civilTime) { + final micros = int.parse((match.group(7) ?? '').padRight(6, '0')); + return DateTime.utc( + parts[0], + parts[1], + parts[2], + parts[3], + parts[4], + parts[5], + micros ~/ 1000, + micros % 1000, + ); + } + final result = DateTime.tryParse(value); + if (result == null || result.year < 1 || result.year > 9999) { + BackupLimits.invalid(); + } + return result; + } +} diff --git a/lib/core/backup/recurring_backup_data.dart b/lib/core/backup/recurring_backup_data.dart index 3852f86e..6b33a982 100644 --- a/lib/core/backup/recurring_backup_data.dart +++ b/lib/core/backup/recurring_backup_data.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'dart:convert'; import 'package:on_time_front/core/database/database.dart'; @@ -115,10 +116,10 @@ class RecurringBackupData { final schedule = RecurrenceCodec.scheduleFromJson( jsonDecode(segment.scheduleJson), ); - final from = DateTime.parse(segment.fromSlot); + final from = _civilSlot(segment.fromSlot); final before = segment.beforeSlot == null ? null - : DateTime.parse(segment.beforeSlot!); + : _civilSlot(segment.beforeSlot!); if (from.isBefore(rule.start) || (before?.isBefore(from) ?? false) || schedule.timeZoneId != rule.timeZoneId || @@ -201,3 +202,5 @@ class RecurringBackupData { } } } + +DateTime _civilSlot(String value) => CivilDateTime.parse(value).toUtcCarrier(); diff --git a/lib/core/backup/restore_staging.dart b/lib/core/backup/restore_staging.dart new file mode 100644 index 00000000..c3328229 --- /dev/null +++ b/lib/core/backup/restore_staging.dart @@ -0,0 +1,135 @@ +import 'backup_private_files.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/core/database/sqlcipher_loader.dart'; +import 'dart:io'; +import 'dart:math'; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_files.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +typedef RestoreStagingFactory = Future Function(); + +/// Actual SQLCipher staging, never a plaintext SQLite fallback. Temporary keys +/// live only in the owner; the installation secure-storage key is untouched. +final class RestoreStaging { + RestoreStaging(this.database, this._release); + final AppDatabase database; + final Future Function() _release; + bool _released = false; + static final _live = BackupPrivateFiles.livePaths; + static Future directory() async => Directory( + p.join( + (await getApplicationSupportDirectory()).path, + 'ontime_restore_staging', + ), + ); + + static Future cleanupAbandoned({Directory? root}) async { + root ??= await directory(); + if (!await root.exists()) return; + await for (final entry in root.list(followLinks: false)) { + if (_live.any( + (path) => entry.path == path || entry.path.startsWith('$path-'), + )) { + continue; + } + // Never follow a link out of our private staging namespace. + await entry.delete(recursive: true); + } + } + + static Future create({ + Directory? root, + Future Function(File)? removeFile, + String? attemptId, + }) async { + configureSqlCipherLoader(); + root ??= await directory(); + await root.create(recursive: true); + final id = attemptId ?? const Uuid().v4(); + if (!RegExp(r'^[a-zA-Z0-9-]{1,64}$').hasMatch(id)) { + throw ArgumentError('Invalid attempt identity'); + } + final file = File(p.join(root.path, '$id.sqlite')); + if (!_live.add(file.path)) throw StateError('Attempt already owned'); + var created = false; + AppDatabase? db; + Future remove() async { + if (!created) { + _live.remove(file.path); + return; + } + for (final suffix in ['', '-wal', '-shm', '-journal']) { + final member = File('${file.path}$suffix'); + if (await member.exists()) { + if (removeFile != null) { + await removeFile(member); + } else { + await member.delete(); + } + } + if (await member.exists()) { + throw StateError('Staging cleanup unconfirmed'); + } + } + _live.remove(file.path); + } + + Future cleanupOwned() async { + await db?.close(); + db = null; + await remove(); + } + + try { + await file.create(exclusive: true); + created = true; + await excludeLocalDatabaseFromPlatformBackup(file); + // Excluding the directory covers sidecars created by SQLite later. + await excludeLocalDatabaseFromPlatformBackup(File(root.path)); + final random = Random.secure(); + final key = List.generate(32, (_) => random.nextInt(256)); + final hex = key.map((n) => n.toRadixString(16).padLeft(2, '0')).join(); + db = AppDatabase.forTesting( + NativeDatabase( + file, + setup: (raw) { + guardEncryptedDatabase( + raw, + hex, + role: DatabaseOpenRole.ownedCreation, + allowCreation: true, + ); + }, + ), + ); + await db!.customSelect('SELECT 1').get(); + return RestoreStaging(db!, cleanupOwned); + } catch (original) { + try { + await cleanupOwned(); + } catch (cleanup) { + throw RestoreStagingCleanupFailure( + originalError: original, + cleanupError: cleanup, + retryCleanup: cleanupOwned, + ); + } + rethrow; + } + } + + Future release() async { + if (_released) return; + try { + await _release(); + _released = true; + } catch (error) { + throw RestoreStagingCleanupFailure(cleanupError: error); + } + } +} diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 723f84e4..7688cdcb 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1,4 +1,7 @@ +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:drift/drift.dart'; +import 'schema_contract.dart'; import 'package:on_time_front/core/database/schedule_aggregate_schema.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/database/installation_key_store.dart'; @@ -48,25 +51,38 @@ part 'database.g.dart'; ) class AppDatabase extends _$AppDatabase { AppDatabase(InstallationKeyStore keyStore) - : super(openOnTimeDatabase(keyStore)); + : super(openOnTimeDatabase(keyStore)) { + StartupDependencyScope.own(this, close); + } AppDatabase.forTesting(super.e); + @disposeMethod + Future disposeFromScope() => + StartupDependencyScope.release(this, close); + @override - int get schemaVersion => 3; + int get schemaVersion => DatabaseSchemaContract.current; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (Migrator m) async { - await m.createAll(); - await installAggregateConcurrency(this); + final existing = await customSelect( + DatabaseSchemaContract.objectsQuery, + ).get(); + if (existing.isNotEmpty) throw const UnsupportedDatabaseSchema(); + await transaction(() async { + await m.createAll(); + await installAggregateConcurrency(this); + await _validateSchema(schemaVersion); + await customStatement('PRAGMA user_version = $schemaVersion'); + }); }, onUpgrade: (Migrator m, int from, int to) async { if (from < 1 || from > schemaVersion || to > schemaVersion) { - throw StateError( - 'Only the encrypted local-only schema can be upgraded.', - ); + throw const UnsupportedDatabaseSchema(); } + await _validateSchema(from); await transaction(() async { if (from < 2) { await m.createTable(preparationDefinitions); @@ -105,13 +121,75 @@ class AppDatabase extends _$AppDatabase { } await installAggregateConcurrency(this); } + if (from < 4) { + await m.addColumn(schedules, schedules.requiresStartConfirmation); + await m.addColumn(users, users.restoreCleanupPending); + await m.addColumn(users, users.rejectLegacyDelivery); + } + await _validateSchema(schemaVersion); + // Drift repeats this write after beforeOpen. Committing it here means + // an error in that later write cannot leave new DDL with an old version. + await customStatement('PRAGMA user_version = $schemaVersion'); }); }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); + await _validateSchema(schemaVersion); }, ); + Future _validateSchema(int version) async { + final rows = await customSelect(DatabaseSchemaContract.objectsQuery).get(); + DatabaseSchemaContract.validate(version, rows.map((r) => r.data).toList()); + for (final row in rows.where((r) => r.read('type') == 'table')) { + final name = row.read('name'); + final quoted = '"${name.replaceAll('"', '""')}"'; + final columns = await customSelect('PRAGMA table_xinfo($quoted)').get(); + final foreign = await customSelect( + 'PRAGMA foreign_key_list($quoted)', + ).get(); + final indexRows = await customSelect('PRAGMA index_list($quoted)').get(); + final indexes = >[]; + for (final index in indexRows) { + final indexName = + '"${index.read('name').replaceAll('"', '""')}"'; + final indexColumns = await customSelect( + 'PRAGMA index_xinfo($indexName)', + ).get(); + indexes.add({ + ...index.data, + 'columns': indexColumns.map((r) => r.data).toList(), + }); + } + DatabaseSchemaContract.validateTableMetadata( + version, + name, + columns.map((r) => r.data).toList(), + foreign.map((r) => r.data).toList(), + indexes, + ); + } + if ((await customSelect('PRAGMA foreign_key_check').get()).isNotEmpty) { + throw const UnsupportedDatabaseSchema(); + } + } + + /// Ordinary mutations share the existing replacement gate but stay available + /// while a non-destructive export picker owns that gate. + Future writeTransaction( + Future Function() action, { + LocalDataOperationGate? gate, + }) { + final owner = gate ?? LocalDataOperationGate.shared; + final generation = owner.captureWrite(); + return transaction(() async { + owner.checkWrite(generation); + final result = await action(); + owner.checkWrite(generation); + return result; + }); + } + Future deleteAllDurableData() async { await transaction(() async { await delete(recurringScheduleExclusions).go(); diff --git a/lib/core/database/encrypted_database_guard.dart b/lib/core/database/encrypted_database_guard.dart new file mode 100644 index 00000000..c5c80630 --- /dev/null +++ b/lib/core/database/encrypted_database_guard.dart @@ -0,0 +1,90 @@ +import 'package:sqlite3/sqlite3.dart'; +import 'schema_contract.dart'; + +enum DatabaseOpenRole { + legacyStartup, + activePairStartup, + candidateValidation, + ownedCreation, +} + +/// Shared SQLCipher and schema guard; it never creates keys or rewrites files. +void guardEncryptedDatabase( + Database raw, + String keyHex, { + required DatabaseOpenRole role, + bool allowCreation = false, +}) { + final cipher = raw.select('PRAGMA cipher_version'); + if (cipher.length != 1 || + cipher.single.values.length != 1 || + cipher.single.values.single is! String || + (cipher.single.values.single as String).trim().isEmpty) { + throw const EncryptedDatabaseUnavailable(); + } + if (!RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(keyHex)) { + throw const EncryptedDatabaseUnavailable(); + } + raw.execute('PRAGMA key = "x\'$keyHex\'"'); + // Reading the schema/version, not merely accepting PRAGMA key, verifies the + // actual engine/key/file combination before Drift obtains migration authority. + final version = raw.userVersion; + final objects = raw + .select(DatabaseSchemaContract.objectsQuery) + .map((row) => Map.from(row)) + .toList(); + if (version == 0) { + if (!allowCreation || + objects.isNotEmpty || + (role != DatabaseOpenRole.ownedCreation && + role != DatabaseOpenRole.legacyStartup)) { + throw const UnsupportedDatabaseSchema(); + } + } else { + final allowed = switch (role) { + DatabaseOpenRole.legacyStartup => DatabaseSchemaContract.legacySupported, + DatabaseOpenRole.activePairStartup => + DatabaseSchemaContract.pairSupported, + DatabaseOpenRole.candidateValidation => [DatabaseSchemaContract.current], + DatabaseOpenRole.ownedCreation => const [], + }; + if (!allowed.contains(version)) throw const UnsupportedDatabaseSchema(); + DatabaseSchemaContract.validate(version, objects); + for (final object in objects.where((o) => o['type'] == 'table')) { + final name = object['name'] as String; + final quoted = '"${name.replaceAll('"', '""')}"'; + final indexes = >[]; + for (final index in raw.select('PRAGMA index_list($quoted)')) { + final indexName = + '"${(index['name'] as String).replaceAll('"', '""')}"'; + indexes.add({ + ...index, + 'columns': raw + .select('PRAGMA index_xinfo($indexName)') + .map((r) => Map.from(r)) + .toList(), + }); + } + DatabaseSchemaContract.validateTableMetadata( + version, + name, + raw.select('PRAGMA table_xinfo($quoted)'), + raw.select('PRAGMA foreign_key_list($quoted)'), + indexes, + ); + } + if (raw.select('PRAGMA quick_check').single.values.single != 'ok' || + raw.select('PRAGMA foreign_key_check').isNotEmpty) { + throw const UnsupportedDatabaseSchema(); + } + } + // Connection-local settings do not grant migration or creation authority. + raw.execute('PRAGMA cipher_memory_security = ON'); + raw.execute('PRAGMA temp_store = MEMORY'); +} + +final class EncryptedDatabaseUnavailable implements Exception { + const EncryptedDatabaseUnavailable(); + @override + String toString() => 'Encrypted local database requires recovery'; +} diff --git a/lib/core/database/initial_store_guard.dart b/lib/core/database/initial_store_guard.dart new file mode 100644 index 00000000..b3d04047 --- /dev/null +++ b/lib/core/database/initial_store_guard.dart @@ -0,0 +1,2 @@ +export 'initial_store_guard_native.dart' + if (dart.library.html) 'initial_store_guard_web.dart'; diff --git a/lib/core/database/initial_store_guard_native.dart b/lib/core/database/initial_store_guard_native.dart new file mode 100644 index 00000000..8ba6d480 --- /dev/null +++ b/lib/core/database/initial_store_guard_native.dart @@ -0,0 +1,233 @@ +import 'recovery/pair_files.dart'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'installation_key_store.dart'; +import 'local_data_files_native.dart'; + +/// Only the first fixed store's creation. D02 extends the evidence resolver +/// before introducing active-pair files; this is not its activation journal. +final class InitialStoreGuard { + InitialStoreGuard( + this.file, + this.keys, { + Future Function(File)? protect, + Future Function()? activationEvidence, + Future Function(File)? removeCompletedReceipt, + }) : _protect = protect ?? excludeLocalDatabaseFromPlatformBackup, + _activationEvidence = activationEvidence, + _removeCompletedReceipt = + removeCompletedReceipt ?? + ((file) async { + await file.delete(); + }); + final File file; + final InstallationKeyStore keys; + final Future Function(File) _protect; + final Future Function()? _activationEvidence; + final Future Function(File) _removeCompletedReceipt; + File get receiptFile => File('${file.path}.creation.json'); + File get temporaryReceipt => File('${receiptFile.path}.tmp'); + static final _openFlights = >{}; + static Future device(InstallationKeyStore keys) async => + InitialStoreGuard( + await localDatabaseFile(), + keys, + activationEvidence: () async => + (await PairFiles.device()).hasEvidence(), + ); + + Future _type(String path) => + FileSystemEntity.type(path, followLinks: false); + Future _exists(String path) async => + await _type(path) != FileSystemEntityType.notFound; + Future> _family() async { + final paths = []; + for (final suffix in ['', '-wal', '-shm', '-journal']) { + final path = '${file.path}$suffix'; + final type = await _type(path); + if (type == FileSystemEntityType.notFound) continue; + if (type != FileSystemEntityType.file) { + throw const LocalStorePreservationRequired(); + } + paths.add(path); + } + return paths; + } + + Future requireNoLocalEvidence() async { + try { + if ((await _family()).isNotEmpty || + await keys.exists() || + await _exists(receiptFile.path) || + await _exists(temporaryReceipt.path) || + await _activationEvidence?.call() == true) { + throw const LocalStorePreservationRequired(); + } + } catch (_) { + throw const LocalStorePreservationRequired(); + } + } + + Future<_Creation?> _read() async { + if (!await _exists(receiptFile.path)) { + if (await _exists(temporaryReceipt.path)) { + throw const LocalStorePreservationRequired(); + } + return null; + } + if (await _type(receiptFile.path) != FileSystemEntityType.file) { + throw const LocalStorePreservationRequired(); + } + try { + final raw = jsonDecode(await receiptFile.readAsString()); + if (raw is! Map || + raw.length != 5 || + raw['version'] != 1 || + raw['store'] != localDatabaseFileName || + raw['keySlot'] != 'installation-v1' || + raw['owner'] is! String || + !RegExp(r'^[a-f0-9-]{36}$').hasMatch(raw['owner'] as String) || + !['reserved', 'keyReady', 'verified'].contains(raw['stage'])) { + throw const LocalStorePreservationRequired(); + } + return _Creation(raw['owner'] as String, raw['stage'] as String); + } catch (_) { + throw const LocalStorePreservationRequired(); + } + } + + Future _write(_Creation receipt) async { + await file.parent.create(recursive: true); + // Parent exclusion also covers SQLite sidecars and interrupted temp writes. + await _protect(File(file.parent.path)); + if (await _exists(temporaryReceipt.path) && + await _type(temporaryReceipt.path) != FileSystemEntityType.file) { + throw const LocalStorePreservationRequired(); + } + final value = jsonEncode(receipt.encode()); + await temporaryReceipt.writeAsString(value, flush: true); + await _protect(temporaryReceipt); + await temporaryReceipt.rename(receiptFile.path); + if (await receiptFile.readAsString() != value) { + throw const LocalStorePreservationRequired(); + } + } + + Future prepareOpen() { + final current = _openFlights[file.path]; + if (current != null) return current; + return _openFlights[file.path] = _prepare().whenComplete(() { + _openFlights.remove(file.path); + }); + } + + Future _prepare() async { + try { + if (await _activationEvidence?.call() == true) { + throw const LocalStorePreservationRequired(); + } + final family = await _family(); + final hasMain = family.contains(file.path); + var receipt = await _read(); + var key = await keys.readExisting(); + if (hasMain) { + if (key == null || + (await file.length() == 0 && receipt?.stage != 'keyReady')) { + throw const LocalStorePreservationRequired(); + } + await _protect(file); + return key; + } + if (family.isNotEmpty || receipt?.stage == 'verified') { + throw const LocalStorePreservationRequired(); + } + if (receipt == null) { + if (key != null) throw const LocalStorePreservationRequired(); + // Recheck all observed evidence before claiming this exact namespace. + await requireNoLocalEvidence(); + receipt = _Creation(const Uuid().v4(), 'reserved'); + await _write(receipt); + } + if (key == null) { + if (receipt.stage != 'reserved') { + throw const LocalStorePreservationRequired(); + } + key = await keys.createVerified(); + } + receipt = _Creation(receipt.owner, 'keyReady'); + await _write(receipt); + // Never truncate a newly appeared file or treat an unexpected family as + // ours. The process-level creation flight prevents duplicate app opens. + if ((await _family()).isNotEmpty) { + throw const LocalStorePreservationRequired(); + } + await file.create(exclusive: true); + await _protect(file); + return key; + } catch (_) { + throw const LocalStorePreservationRequired(); + } + } + + /// Only this installation's durable initial-creation intent authorizes an + /// empty schema. Existing version-zero files do not grant their own authority. + Future allowsSchemaCreation() async => + (await _read())?.stage == 'keyReady'; + + /// Called only after SQLCipher, schema migration and identity read succeeded. + /// A failed final unlink leaves the same pair and owned receipt for retry. + Future completeVerifiedCreation() async { + final receipt = await _read(); + if (receipt == null) return; + if (!await file.exists() || + await file.length() == 0 || + await keys.readExisting() == null) { + throw const LocalStorePreservationRequired(); + } + await _write(_Creation(receipt.owner, 'verified')); + await _removeCompletedReceipt(receiptFile); + if (await _exists(receiptFile.path) || + await _exists(temporaryReceipt.path)) { + throw const LocalStorePreservationRequired(); + } + } + + static Future removeDeviceReceipt() async => + (await device(InstallationKeyStore())).removeReceipt(); + + Future removeReceipt() async { + for (final suffix in ['.creation.json', '.creation.json.tmp']) { + final path = '${file.path}$suffix'; + final type = await FileSystemEntity.type(path, followLinks: false); + if (type == FileSystemEntityType.notFound) continue; + if (type == FileSystemEntityType.directory) { + throw const LocalStorePreservationRequired(); + } + if (type == FileSystemEntityType.link) { + await Link(path).delete(); + } else { + await File(path).delete(); + } + if (await FileSystemEntity.type(path, followLinks: false) != + FileSystemEntityType.notFound) { + throw const LocalStorePreservationRequired(); + } + } + } +} + +final class _Creation { + const _Creation(this.owner, this.stage); + final String owner; + final String stage; + Map encode() => { + 'version': 1, + 'store': localDatabaseFileName, + 'keySlot': 'installation-v1', + 'owner': owner, + 'stage': stage, + }; +} diff --git a/lib/core/database/initial_store_guard_web.dart b/lib/core/database/initial_store_guard_web.dart new file mode 100644 index 00000000..d1ed7ce9 --- /dev/null +++ b/lib/core/database/initial_store_guard_web.dart @@ -0,0 +1,9 @@ +import 'installation_key_store.dart'; + +final class InitialStoreGuard { + static Future device(InstallationKeyStore keys) async => + InitialStoreGuard(); + Future requireNoLocalEvidence() async {} + Future completeVerifiedCreation() async {} + static Future removeDeviceReceipt() async {} +} diff --git a/lib/core/database/installation_key_store.dart b/lib/core/database/installation_key_store.dart index cd1be2d8..23d835cf 100644 --- a/lib/core/database/installation_key_store.dart +++ b/lib/core/database/installation_key_store.dart @@ -18,28 +18,57 @@ class InstallationKeyStore { final FlutterSecureStorage _storage; - Future getOrCreate() async { + Future readExisting() async { final encoded = await _storage.read(key: _keyName, iOptions: _iosOptions); - if (encoded != null) { - final decoded = base64Url.decode(encoded); - if (decoded.length != _keyLength) { - throw const FormatException('Invalid installation data key length.'); - } - return Uint8List.fromList(decoded); + if (encoded == null) return null; + final decoded = base64Url.decode(encoded); + if (decoded.length != _keyLength) { + throw const FormatException('Invalid installation data key length.'); } + return Uint8List.fromList(decoded); + } + /// Only the initial-store ownership guard authorizes this product operation. + Future createVerified() async { + final existing = await readExisting(); + if (existing != null) return existing; final random = Random.secure(); final key = Uint8List.fromList( List.generate(_keyLength, (_) => random.nextInt(256)), ); - await _storage.write( - key: _keyName, - value: base64UrlEncode(key), - iOptions: _iosOptions, - ); - return key; + try { + await _storage.write( + key: _keyName, + value: base64UrlEncode(key), + iOptions: _iosOptions, + ); + } catch (_) { + // A failed response may still have persisted the value. Never write a + // second key to make an uncertain first write look successful. + final observed = await readExisting(); + if (observed == null || !_equal(key, observed)) rethrow; + return observed; + } + final observed = await readExisting(); + if (observed == null || !_equal(key, observed)) { + throw StateError('Installation key write unconfirmed'); + } + return observed; } + static bool _equal(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + // Kept for explicit key-store fixtures. The product opener uses its ownership + // guard and never invokes implicit creation for an existing store. + Future getOrCreate() async => + await readExisting() ?? await createVerified(); + Future delete() => _storage.delete(key: _keyName, iOptions: _iosOptions); diff --git a/lib/core/database/local_data_files_native.dart b/lib/core/database/local_data_files_native.dart index 9f44dadd..dba3af79 100644 --- a/lib/core/database/local_data_files_native.dart +++ b/lib/core/database/local_data_files_native.dart @@ -22,6 +22,10 @@ Future deleteLocalDatabaseFiles({required bool includeLegacy}) async { await _deleteSqliteFamily(current.path); if (!includeLegacy) return; + await deleteLegacyDatabaseFiles(); +} + +Future deleteLegacyDatabaseFiles() async { final documents = await getApplicationDocumentsDirectory(); for (final name in const ['my_database.sqlite', 'my_database']) { await _deleteSqliteFamily(p.join(documents.path, name)); diff --git a/lib/core/database/local_data_files_web.dart b/lib/core/database/local_data_files_web.dart index c8ebf205..1f60937e 100644 --- a/lib/core/database/local_data_files_web.dart +++ b/lib/core/database/local_data_files_web.dart @@ -12,3 +12,5 @@ Future excludeLocalDatabaseFromPlatformBackup( ) async {} Future deleteLocalDatabaseFiles({required bool includeLegacy}) async {} + +Future deleteLegacyDatabaseFiles() async {} diff --git a/lib/core/database/local_data_lifecycle.dart b/lib/core/database/local_data_lifecycle.dart index b03317d6..4b340af8 100644 --- a/lib/core/database/local_data_lifecycle.dart +++ b/lib/core/database/local_data_lifecycle.dart @@ -1,3 +1,8 @@ +import 'recovery/pair_files.dart'; +import 'package:on_time_front/core/database/initial_store_guard.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:on_time_front/core/database/local_data_files.dart'; import 'package:on_time_front/core/database/local_reset_actions.dart'; @@ -24,21 +29,72 @@ final class LocalDataLifecycle { ); static Future? _resetInFlight; - static Future bootstrap() async { + static Future bootstrap({Future Function()? preparePairs}) async { final reset = await resumeReset(); if (reset.recoveryRequired) throw LocalResetRecoveryRequired(reset); - if (await _storage.read(key: _cutoverMarker) == null) { + await preparePairs?.call(); + // Pair authority is resolved before fixed-store/cutover classification. + final pair = await (await PairFiles.device()).selected(); + if (!pair.isLegacy) return; + final String? marker; + try { + marker = await _storage.read(key: _cutoverMarker); + } catch (_) { + throw const LocalStorePreservationRequired(); + } + if (marker != null && marker != 'complete') { + throw const LocalStorePreservationRequired(); + } + if (marker == null) { + await (await InitialStoreGuard.device( + InstallationKeyStore(), + )).requireNoLocalEvidence(); await _performOneWayCutover(); } } + /// Only called after the explicit restored pair was opened and verified by + /// recovery startup. Never the normal marker-error fallback. + static Future repairCutoverAfterVerifiedRestore() async { + final marker = await _storage.read(key: _cutoverMarker); + if (marker == 'complete') return; + await deleteLegacyDatabaseFiles(); + for (final key in _legacyTokenKeys) { + await _storage.delete(key: key); + await _storage.delete(key: key, iOptions: _legacyIosOptions); + if (await _storage.read(key: key) != null || + await _storage.read(key: key, iOptions: _legacyIosOptions) != null) { + throw const LocalStorePreservationRequired(); + } + } + await _storage.write(key: _cutoverMarker, value: 'complete'); + if (await _storage.read(key: _cutoverMarker) != 'complete') { + throw const LocalStorePreservationRequired(); + } + } + static Future markResetPending() => DeviceLocalResetActions().writeMarker(); /// No DB or DI is opened while an interrupted reset remains unconfirmed. - static Future resumeReset() { + static Future resumeReset() => _reset(begin: false); + + /// Called only after the recovery UI has obtained destructive confirmation. + static Future beginReset() { + if (LocalDataOperationGate.shared.isRecoveryPending) { + return Future.error(const LocalDataUnavailable()); + } + return _reset(begin: true); + } + + static bool _resetBegin = false; + static Future _reset({required bool begin}) { final running = _resetInFlight; - if (running != null) return running; + if (running != null) { + if (begin && !_resetBegin) throw StateError('Reset recovery is busy'); + return running; + } + _resetBegin = begin; final owner = AlarmOperationCoordinator.shared; _resetInFlight = owner .cleanup(() { @@ -54,14 +110,15 @@ final class LocalDataLifecycle { owner, ), DeviceLocalResetActions(), - ).run(begin: false); + onIntent: LocalDataOperationGate.shared.invalidate, + ).run(begin: begin); }) .whenComplete(() => _resetInFlight = null); return _resetInFlight!; } static Future _performOneWayCutover() async { - await deleteLocalDatabaseFiles(includeLegacy: true); + await deleteLegacyDatabaseFiles(); if (!await (await SharedPreferences.getInstance()).clear()) { throw StateError('Local cutover preferences cleanup failed'); } @@ -70,5 +127,8 @@ final class LocalDataLifecycle { await _storage.delete(key: key, iOptions: _legacyIosOptions); } await _storage.write(key: _cutoverMarker, value: 'complete'); + if (await _storage.read(key: _cutoverMarker) != 'complete') { + throw const LocalStorePreservationRequired(); + } } } diff --git a/lib/core/database/local_data_operation_gate.dart b/lib/core/database/local_data_operation_gate.dart index 1e5ed09e..4b8987b6 100644 --- a/lib/core/database/local_data_operation_gate.dart +++ b/lib/core/database/local_data_operation_gate.dart @@ -8,12 +8,35 @@ final class LocalDataOperationGate extends ChangeNotifier { bool _busy = false; bool _unavailable = false; bool _replacingData = false; + bool _recoveryPending = false; + + bool get isRecoveryPending => _recoveryPending; + void setRecoveryPending(bool pending) { + if (_recoveryPending == pending) return; + _recoveryPending = pending; + notifyListeners(); + } + int _generation = 0; int get generation => _generation; bool get isReplacingData => _replacingData; bool get isInvalidated => _unavailable; - bool get isAvailable => !_busy && !_unavailable; + bool get isAvailable => !_busy && !_unavailable && !_recoveryPending; + + int captureWrite() { + checkWrite(_generation); + return _generation; + } + + void checkWrite(int generation) { + if (generation != _generation || + _replacingData || + _recoveryPending || + _unavailable) { + throw const LocalDataUnavailable(); + } + } Future run( Future Function() action, { @@ -21,7 +44,7 @@ final class LocalDataOperationGate extends ChangeNotifier { Future Function()? validateReplacement, }) async { if (_busy) throw const LocalDataOperationBusy(); - if (_unavailable) throw const LocalDataUnavailable(); + if (_unavailable || _recoveryPending) throw const LocalDataUnavailable(); _busy = true; try { // The exclusive claim is held before validation, but its replacement diff --git a/lib/core/database/local_reset_actions.dart b/lib/core/database/local_reset_actions.dart index 8e82b658..af88c422 100644 --- a/lib/core/database/local_reset_actions.dart +++ b/lib/core/database/local_reset_actions.dart @@ -1,3 +1,5 @@ +import 'recovery/pair_files.dart'; +import 'package:on_time_front/core/database/initial_store_guard.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:on_time_front/core/database/installation_key_store.dart'; @@ -16,13 +18,24 @@ final class DeviceLocalResetActions InstallationKeyStore? keyStore, this.closeDatabase, Future Function()? deleteFiles, + Future Function()? removeCreationReceipt, + Future Function()? removePairKeys, Future Function()? clearDeliveries, Future Function()? clearNativeDeliveries, Future Function()? clearLaunch, }) : storage = storage ?? const FlutterSecureStorage(), keyStore = keyStore ?? InstallationKeyStore(), deleteFiles = - deleteFiles ?? (() => deleteLocalDatabaseFiles(includeLegacy: true)), + deleteFiles ?? + (() async { + await (await PairFiles.device()).resetFiles(); + await deleteLocalDatabaseFiles(includeLegacy: true); + }), + removePairKeys = + removePairKeys ?? + (() async => (await PairFiles.device()).resetKeysAndMetadata()), + removeCreationReceipt = + removeCreationReceipt ?? InitialStoreGuard.removeDeviceReceipt, clearDeliveries = clearDeliveries ?? _clearDeliveries, clearNativeDeliveries = clearNativeDeliveries ?? _clearNativeDeliveries, clearLaunch = clearLaunch ?? _clearLaunch; @@ -36,6 +49,8 @@ final class DeviceLocalResetActions final InstallationKeyStore keyStore; final Future Function()? closeDatabase; final Future Function() deleteFiles; + final Future Function() removeCreationReceipt; + final Future Function() removePairKeys; final Future Function() clearDeliveries; final Future Function() clearNativeDeliveries; bool _allProvidersConfirmedEmpty = false; @@ -69,12 +84,14 @@ final class DeviceLocalResetActions case ResetStep.database: await closeDatabase?.call(); await deleteFiles(); + await removeCreationReceipt(); case ResetStep.preferences: final prefs = await SharedPreferences.getInstance(); if (!await prefs.clear()) throw const AlarmJournalUnavailable(); await prefs.reload(); if (prefs.getKeys().isNotEmpty) throw const AlarmJournalUnavailable(); case ResetStep.key: + await removePairKeys(); await keyStore.delete(); if (await keyStore.exists()) throw const AlarmJournalUnavailable(); case ResetStep.credentials: diff --git a/lib/core/database/open_database_native.dart b/lib/core/database/open_database_native.dart index 2739f5c4..f393ec4e 100644 --- a/lib/core/database/open_database_native.dart +++ b/lib/core/database/open_database_native.dart @@ -1,24 +1,50 @@ +import 'encrypted_database_guard.dart'; +import 'recovery/pair_files.dart'; +import 'recovery/store_pair.dart'; +import 'package:on_time_front/core/database/initial_store_guard.dart'; +import 'package:on_time_front/core/database/sqlcipher_loader.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:on_time_front/core/database/installation_key_store.dart'; -import 'package:on_time_front/core/database/local_data_files.dart'; QueryExecutor openOnTimeDatabase(InstallationKeyStore keyStore) { return LazyDatabase(() async { - final file = await localDatabaseFile(); - await file.parent.create(recursive: true); - if (!await file.exists()) await file.create(); - await excludeLocalDatabaseFromPlatformBackup(file); - final key = await keyStore.getOrCreate(); + final files = await PairFiles.device(); + final pair = await files.selected(); + final file = files.database(pair); + final state = await files.readRecord(); + if (!pair.isLegacy && + state?.stage != PairStage.ready && + state?.stage != PairStage.cancelled) { + throw const PairAuthorityUnavailable(); + } + if (!pair.isLegacy) await files.requireExisting(pair); + final initial = InitialStoreGuard( + file, + keyStore, + activationEvidence: files.hasEvidence, + ); + final key = pair.isLegacy + ? await initial.prepareOpen() + : await files.keys.read(pair); + final allowCreation = pair.isLegacy && await initial.allowsSchemaCreation(); + if (key == null) throw const PairAuthorityUnavailable(); final keyHex = key .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); return NativeDatabase.createInBackground( file, + isolateSetup: configureSqlCipherLoader, setup: (rawDatabase) { - rawDatabase.execute('PRAGMA key = "x\'$keyHex\'"'); - rawDatabase.execute('PRAGMA cipher_memory_security = ON'); + guardEncryptedDatabase( + rawDatabase, + keyHex, + role: pair.isLegacy + ? DatabaseOpenRole.legacyStartup + : DatabaseOpenRole.activePairStartup, + allowCreation: allowCreation, + ); }, ); }); diff --git a/lib/core/database/recovery/encrypted_pair_database.dart b/lib/core/database/recovery/encrypted_pair_database.dart new file mode 100644 index 00000000..90ce548e --- /dev/null +++ b/lib/core/database/recovery/encrypted_pair_database.dart @@ -0,0 +1,115 @@ +import '../encrypted_database_guard.dart'; +import '../schema_contract.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:drift/native.dart'; +import '../database.dart'; +import '../sqlcipher_loader.dart'; +import 'pair_files.dart'; +import 'store_pair.dart'; + +Future openEncryptedPair( + PairFiles files, + StorePair pair, { + bool creating = false, + bool startupMigration = false, +}) async { + final file = files.database(pair); + await files.regular(file.path); + if (!await file.exists()) throw const PairAuthorityUnavailable(); + if (!creating) await files.requireExisting(pair); + final key = await files.keys.read(pair); + if (key == null) throw const PairAuthorityUnavailable(); + configureSqlCipherLoader(); + final hex = key.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final db = AppDatabase.forTesting( + NativeDatabase( + file, + setup: (raw) { + guardEncryptedDatabase( + raw, + hex, + role: creating + ? DatabaseOpenRole.ownedCreation + : startupMigration + ? DatabaseOpenRole.activePairStartup + : DatabaseOpenRole.candidateValidation, + allowCreation: creating, + ); + }, + ), + ); + try { + await db.customSelect('SELECT count(*) FROM sqlite_master').get(); + return db; + } catch (_) { + await db.close(); + rethrow; + } +} + +/// Reads an existing original without running Drift migrations, creating a DB, +/// or changing its runtime. A now-readable store requires normal A09 confirmation. +Future originalPairReadable(PairFiles files, StorePair pair) async { + final file = files.database(pair); + await files.regular(file.path); + if (!await file.exists() || await file.length() == 0) return false; + final List? key; + try { + key = await files.keys.read(pair); + } on PairAuthorityUnavailable { + return false; + } + if (key == null) return false; + configureSqlCipherLoader(); + final raw = sqlite3.open(file.path, mode: OpenMode.readOnly); + try { + final hex = key.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + guardEncryptedDatabase( + raw, + hex, + role: pair.isLegacy + ? DatabaseOpenRole.legacyStartup + : DatabaseOpenRole.activePairStartup, + ); + final version = raw.userVersion; + if (version < 3) { + final profiles = raw.select('SELECT id FROM users'); + return pair.isLegacy && + profiles.length == 1 && + profiles.single['id'] == 'local-profile'; + } + if (version == 3) { + final profiles = raw.select('SELECT id, store_incarnation FROM users'); + return pair.isLegacy && + profiles.length == 1 && + profiles.single['id'] == 'local-profile' && + profiles.single['store_incarnation'] is String && + RegExp( + r'^[a-f0-9]{32}$', + ).hasMatch(profiles.single['store_incarnation'] as String); + } + final rows = raw.select( + 'SELECT id, store_incarnation, reject_legacy_delivery, restore_cleanup_pending FROM users', + ); + if (rows.length != 1) return false; + final row = rows.single; + final runtime = row['store_incarnation']; + final reject = row['reject_legacy_delivery']; + final pending = row['restore_cleanup_pending']; + return row['id'] == 'local-profile' && + runtime is String && + RegExp(r'^[a-f0-9]{32}$').hasMatch(runtime) && + (reject == 0 || reject == 1) && + (pending == 0 || pending == 1) && + (pair.isLegacy || reject == 1) && + (pending == 0 || reject == 1); + } on SqliteException { + return false; + } on UnsupportedDatabaseSchema { + return false; + } on EncryptedDatabaseUnavailable { + return false; + } finally { + raw.dispose(); + } +} diff --git a/lib/core/database/recovery/native_process_identity.dart b/lib/core/database/recovery/native_process_identity.dart new file mode 100644 index 00000000..46f4558e --- /dev/null +++ b/lib/core/database/recovery/native_process_identity.dart @@ -0,0 +1,12 @@ +import 'package:flutter/services.dart'; +import 'store_pair.dart'; + +Future readNativeProcessIdentity() async { + final value = await const MethodChannel( + 'on_time_front/native_alarm', + ).invokeMethod('getProcessIdentity'); + if (value == null || !StorePair.isCandidateId(value)) { + throw const PairAuthorityUnavailable(); + } + return value; +} diff --git a/lib/core/database/recovery/pair_files.dart b/lib/core/database/recovery/pair_files.dart new file mode 100644 index 00000000..428173bf --- /dev/null +++ b/lib/core/database/recovery/pair_files.dart @@ -0,0 +1,453 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import '../local_data_files_native.dart'; +import 'pair_key_store.dart'; +import 'store_pair.dart'; + +/// Fixed installation namespaces. A backup can never name a path or key slot. +/// Flush + rename + read-back provides process-restart evidence, not a promise +/// of directory fsync or power-loss durability. +final class PairFiles { + PairFiles( + this.support, + this.keys, { + Future Function(File)? protect, + this.checkpoint, + }) : protect = protect ?? excludeLocalDatabaseFromPlatformBackup; + final Directory support; + final PairKeyStore keys; + final Future Function(File) protect; + final Future Function(String)? checkpoint; + static Future device() async => + PairFiles(await getApplicationSupportDirectory(), PairKeyStore()); + Directory get metadata => + Directory(p.join(support.path, 'ontime_pair_state')); + Directory get pairs => + Directory(p.join(support.path, 'ontime_recovery_pairs')); + File get manifest => File(p.join(metadata.path, 'active-v1.json')); + File get record => File(p.join(metadata.path, 'activation-v1.json')); + File database(StorePair pair) => File( + pair.isLegacy + ? p.join(support.path, localDatabaseFileName) + : p.join(pairs.path, '${pair.id}.sqlite'), + ); + Future exists(String path) async => + await FileSystemEntity.type(path, followLinks: false) != + FileSystemEntityType.notFound; + Future checkNamespaces() async { + for (final dir in [metadata, pairs]) { + final type = await FileSystemEntity.type(dir.path, followLinks: false); + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.directory) { + throw const PairAuthorityUnavailable(); + } + } + } + + Future regular(String path) async { + await checkNamespaces(); + final type = await FileSystemEntity.type(path, followLinks: false); + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.file) { + throw const PairAuthorityUnavailable(); + } + } + + Future prepareDirectories() async { + for (final dir in [support, metadata, pairs]) { + final type = await FileSystemEntity.type(dir.path, followLinks: false); + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.directory) { + throw const PairAuthorityUnavailable(); + } + await dir.create(recursive: true); + await protect(File(dir.path)); + } + } + + Future _text(File file) async { + await regular(file.path); + if (!await file.exists()) return null; + if (await file.length() > 16384) throw const PairAuthorityUnavailable(); + return file.readAsString(); + } + + Future readManifest() async { + final text = await _text(manifest); + if (text == null) return null; + try { + final raw = jsonDecode(text); + if (raw is! Map || + raw.length != 2 || + raw['version'] != 1) { + throw const PairAuthorityUnavailable(); + } + final pair = StorePair.decode(raw['pair']); + if (pair.isLegacy) throw const PairAuthorityUnavailable(); + return pair; + } catch (_) { + throw const PairAuthorityUnavailable(); + } + } + + Future readRecord() async { + final text = await _text(record); + final staged = await _text(File('${record.path}.pending')); + if (text == null) { + // A reserved first record is safe to publish: it precedes key/file effects. + if (staged == null) return null; + final pending = PairRecord.decode(jsonDecode(staged)); + if (pending.stage != PairStage.reserved) { + throw const PairAuthorityUnavailable(); + } + await File('${record.path}.pending').rename(record.path); + if (await _text(record) != staged) throw const PairAuthorityUnavailable(); + return pending; + } + final value = PairRecord.decode(jsonDecode(text)); + if (staged != null) { + final pending = PairRecord.decode(jsonDecode(staged)); + final replacingFinished = + (value.stage == PairStage.ready || + value.stage == PairStage.cancelled) && + pending.stage == PairStage.reserved && + pending.original == + (value.stage == PairStage.ready ? value.target : value.original); + if (!replacingFinished && + (pending.target != value.target || + pending.original != value.original || + pending.originProcess != value.originProcess || + pending.stage.index < value.stage.index)) { + throw const PairAuthorityUnavailable(); + } + // Only the committed record authorized side effects. Never promote later + // intent/verification based solely on a valid pending JSON write. + } + return value; + } + + Future _write(File file, String value, String name) async { + await prepareDirectories(); + await regular(file.path); + final pending = File('${file.path}.pending'); + await regular(pending.path); + await pending.writeAsString(value, flush: true); + await protect(pending); + await checkpoint?.call('$name.written'); + if (await _text(pending) != value) throw const PairAuthorityUnavailable(); + await pending.rename(file.path); + await checkpoint?.call('$name.renamed'); + if (await _text(file) != value) throw const PairAuthorityUnavailable(); + await checkpoint?.call('$name.readBack'); + } + + Future writeRecord(PairRecord value) async { + await readRecord(); // Do not overwrite unreadable/future authority. + await _write( + record, + jsonEncode(value.encode()), + 'record.${value.stage.name}', + ); + } + + String manifestBytes(StorePair value) => + jsonEncode({'version': 1, 'pair': value.id}); + Future publish(StorePair value, {StorePair? expected}) async { + final current = await readManifest(); + if (current != expected && current != value) { + throw const PairAuthorityUnavailable(); + } + final pending = await _text(File('${manifest.path}.pending')); + if (pending != null && pending != manifestBytes(value)) { + throw const PairAuthorityUnavailable(); + } + await _write(manifest, manifestBytes(value), 'manifest'); + } + + Future requireExisting(StorePair pair) async { + final file = database(pair); + for (final suffix in ['', '-wal', '-shm', '-journal']) { + await regular('${file.path}$suffix'); + } + if (!await file.exists() || await file.length() == 0) { + throw const PairAuthorityUnavailable(); + } + } + + Future checkIntent( + PairRecord value, { + bool forAbort = false, + bool firstActivation = false, + }) async { + final current = await readRecord(); + if (current == null || + jsonEncode(current.encode()) != jsonEncode(value.encode())) { + throw const PairAuthorityUnavailable(); + } + final staged = await _text(File('${record.path}.pending')); + if (staged != null) { + final next = PairRecord.decode(jsonDecode(staged)); + if (firstActivation && next.stage != PairStage.confirmed) { + throw const PairAuthorityUnavailable(); + } + if (next.target != value.target || + next.original != value.original || + next.originProcess != value.originProcess || + next.originalEvidence != value.originalEvidence || + next.runtimeIdentity != value.runtimeIdentity || + (forAbort && + next.stage != PairStage.confirmed && + next.stage != PairStage.aborting && + !(value.stage == PairStage.aborting && + next.stage == PairStage.cancelled))) { + throw const PairAuthorityUnavailable(); + } + } + final pending = await _text(File('${manifest.path}.pending')); + if (pending != null && pending != manifestBytes(value.target)) { + throw const PairAuthorityUnavailable(); + } + if (await exists(resetCompletion.path) || + await exists('${resetCompletion.path}.pending')) { + throw const PairAuthorityUnavailable(); + } + await checkNamespaces(); + if (await pairs.exists()) { + final names = { + for (final pair in [value.original, value.target]) + if (!pair.isLegacy) + for (final suffix in ['', '-wal', '-shm', '-journal']) + '${pair.id}.sqlite$suffix', + }; + await for (final entry in pairs.list(followLinks: false)) { + if (!names.contains(p.basename(entry.path))) { + throw const PairAuthorityUnavailable(); + } + await regular(entry.path); + } + } + } + + Future removeOwnedPublication(PairRecord value) async { + await checkIntent(value, forAbort: true); + final pending = File('${manifest.path}.pending'); + if (await pending.exists()) await pending.delete(); + if (await exists(pending.path)) throw const PairAuthorityUnavailable(); + await checkpoint?.call('abort.manifestPending.removed'); + } + + Future hasEvidence() async { + if (await keys.hasHistory()) return true; + for (final dir in [metadata, pairs]) { + final type = await FileSystemEntity.type(dir.path, followLinks: false); + if (type == FileSystemEntityType.notFound) continue; + if (type != FileSystemEntityType.directory) { + throw const PairAuthorityUnavailable(); + } + if (!await dir.list(followLinks: false).isEmpty) return true; + } + return false; + } + + Future selected() async { + if (await exists(resetCompletion.path) || + await exists('${resetCompletion.path}.pending')) { + throw const PairAuthorityUnavailable(); + } + final selected = await readManifest(); + final pending = await _text(File('${manifest.path}.pending')); + if (pending != null && + (selected == null || pending != manifestBytes(selected))) { + throw const PairAuthorityUnavailable(); + } + final state = await readRecord(); + if (selected != null) { + if (state == null || + (selected != state.target && selected != state.original) || + (selected == state.target && !state.confirmed)) { + throw const PairAuthorityUnavailable(); + } + return selected; + } + if (state != null && + !state.confirmed && + state.original.isLegacy && + !await keys.hasHistory()) { + return state + .original; // Explicit unconfirmed reservation lineage, no activation. + } + if (await hasEvidence()) throw const PairAuthorityUnavailable(); + return const StorePair.legacy(); + } + + /// Encrypted bytes and opaque slot value are hashed only as bounded local + /// currentness evidence. No plaintext/secret is written into metadata. + Future evidence(StorePair pair) async { + final values = []; + for (final suffix in ['', '-wal', '-shm', '-journal']) { + final file = File('${database(pair).path}$suffix'); + await regular(file.path); + values.add( + await file.exists() + ? '$suffix:${await sha256.bind(file.openRead()).first}' + : '$suffix:absent', + ); + } + values.add( + 'key:${sha256.convert(utf8.encode(await keys.raw(pair) ?? 'absent'))}', + ); + return sha256.convert(utf8.encode(values.join('|'))).toString(); + } + + Future removeFamily(StorePair pair) async { + for (final suffix in ['', '-wal', '-shm', '-journal']) { + final file = File('${database(pair).path}$suffix'); + await regular(file.path); + if (await file.exists()) await file.delete(); + if (await exists(file.path)) throw const PairAuthorityUnavailable(); + await checkpoint?.call('family.$suffix.removed'); + } + } + + Future removeRecord() async { + for (final file in [File('${record.path}.pending'), record]) { + await regular(file.path); + if (await file.exists()) await file.delete(); + if (await exists(file.path)) throw const PairAuthorityUnavailable(); + } + } + + File get resetCompletion => File(p.join(metadata.path, 'reset-keys-v1.json')); + Future?> _resetSlots() async { + final text = await _text(resetCompletion); + final pending = await _text(File('${resetCompletion.path}.pending')); + Set parse(String value) { + try { + final raw = jsonDecode(value); + if (raw is! Map || + raw.length != 2 || + raw['version'] != 1 || + raw['slots'] is! List) { + throw const PairAuthorityUnavailable(); + } + final list = (raw['slots'] as List).map(StorePair.decode).toList(); + final slots = list.toSet(); + if (list.isEmpty || + list.length > 3 || + slots.length != list.length || + !slots.contains(const StorePair.legacy())) { + throw const PairAuthorityUnavailable(); + } + return slots; + } catch (_) { + throw const PairAuthorityUnavailable(); + } + } + + final staged = pending == null ? null : parse(pending); + if (text == null) { + if (staged != null) { + final state = await readRecord(); + final expected = { + const StorePair.legacy(), + if (state != null) state.target, + if (state != null) state.original, + }; + if (staged.length != expected.length || !staged.containsAll(expected)) { + throw const PairAuthorityUnavailable(); + } + } + return null; // No metadata was removed before receipt rename/read-back. + } + final slots = parse(text); + final state = await readRecord(); + final active = await readManifest(); + if (state != null) { + final owned = {const StorePair.legacy(), state.target, state.original}; + if (slots.length != owned.length || !slots.containsAll(owned)) { + throw const PairAuthorityUnavailable(); + } + } + if (active != null && !slots.contains(active)) { + throw const PairAuthorityUnavailable(); + } + if (staged != null && + (staged.length != slots.length || !staged.containsAll(slots))) { + throw const PairAuthorityUnavailable(); + } + return slots; + } + + Future> inventory() async { + final completing = await _resetSlots(); + if (completing != null) return completing; + final state = await readRecord(); + final active = await readManifest(); + if (state == null && (active != null || await hasEvidence())) { + throw const PairAuthorityUnavailable(); + } + if (state != null && + active != null && + active != state.target && + active != state.original) { + throw const PairAuthorityUnavailable(); + } + return { + const StorePair.legacy(), + if (state != null) state.target, + if (state != null) state.original, + ?active, + }; + } + + /// Called only by D03 after explicit reset intent is persisted. Retain record + /// and manifest until the separate key stage has confirmed all slot removals. + Future resetFiles() async { + for (final pair in await inventory()) { + await removeFamily(pair); + } + } + + Future resetKeysAndMetadata() async { + final pairs = await inventory(); + for (final pair in pairs) { + await keys.remove(pair); + } + for (final pair in pairs) { + if (await keys.raw(pair) != null) throw const PairAuthorityUnavailable(); + } + // A reset-specific slot receipt survives metadata removal. Only D03's + // persisted reset intent calls these methods; normal selection never trusts + // this receipt to create a new store. + await _write( + resetCompletion, + jsonEncode({'version': 1, 'slots': pairs.map((p) => p.id).toList()}), + 'resetKeys', + ); + // Keep an independent history witness until all metadata is removed. + for (final file in [ + File('${manifest.path}.pending'), + manifest, + File('${record.path}.pending'), + record, + ]) { + await regular(file.path); + if (await file.exists()) await file.delete(); + if (await exists(file.path)) throw const PairAuthorityUnavailable(); + await checkpoint?.call('resetMetadata.${p.basename(file.path)}.removed'); + } + await keys.removeHistory(); + await checkpoint?.call('resetHistory.removed'); + for (final file in [ + File('${resetCompletion.path}.pending'), + resetCompletion, + ]) { + await regular(file.path); + if (await file.exists()) await file.delete(); + if (await exists(file.path)) throw const PairAuthorityUnavailable(); + } + } +} diff --git a/lib/core/database/recovery/pair_key_store.dart b/lib/core/database/recovery/pair_key_store.dart new file mode 100644 index 00000000..5b499262 --- /dev/null +++ b/lib/core/database/recovery/pair_key_store.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'store_pair.dart'; + +/// Small secure-storage adapter for explicit pair slots. No implicit rotation. +final class PairKeyStore { + PairKeyStore({FlutterSecureStorage? storage}) + : storage = storage ?? const FlutterSecureStorage(); + final FlutterSecureStorage storage; + static const options = IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ); + static const historyKey = 'ontime_store_pair_used_v1'; + String slot(StorePair pair) => pair.isLegacy + ? 'ontime_local_database_key_v1' + : 'ontime_recovery_key_v1_${pair.id}'; + Future raw(StorePair pair) => + storage.read(key: slot(pair), iOptions: options); + Future read(StorePair pair) async { + final value = await raw(pair); + if (value == null) return null; + try { + final bytes = base64Url.decode(value); + if (bytes.length != 32) throw const PairAuthorityUnavailable(); + return Uint8List.fromList(bytes); + } catch (_) { + throw const PairAuthorityUnavailable(); + } + } + + Future create(StorePair pair) async { + if (pair.isLegacy || await raw(pair) != null) { + throw const PairAuthorityUnavailable(); + } + final random = Random.secure(); + final value = base64UrlEncode( + List.generate(32, (_) => random.nextInt(256)), + ); + try { + await storage.write(key: slot(pair), value: value, iOptions: options); + } catch (_) { + if (await raw(pair) != value) rethrow; + } + if (await raw(pair) != value) throw const PairAuthorityUnavailable(); + return (await read(pair))!; + } + + Future remove(StorePair pair) async { + await storage.delete(key: slot(pair), iOptions: options); + if (await raw(pair) != null) throw const PairAuthorityUnavailable(); + } + + Future hasHistory() async { + final value = await storage.read(key: historyKey, iOptions: options); + if (value != null && value != '1') throw const PairAuthorityUnavailable(); + return value != null; + } + + Future markHistory() async { + // Never normalize an invalid/future marker by overwriting it. + if (await hasHistory()) return; + try { + await storage.write(key: historyKey, value: '1', iOptions: options); + } catch (_) { + if (await hasHistory()) { + return; // Only exact read-back proves response loss. + } + rethrow; + } + if (!await hasHistory()) throw const PairAuthorityUnavailable(); + } + + Future removeHistory() async { + await storage.delete(key: historyKey, iOptions: options); + if (await hasHistory()) throw const PairAuthorityUnavailable(); + } +} diff --git a/lib/core/database/recovery/recovery_restore_service.dart b/lib/core/database/recovery/recovery_restore_service.dart new file mode 100644 index 00000000..e2b643e0 --- /dev/null +++ b/lib/core/database/recovery/recovery_restore_service.dart @@ -0,0 +1,966 @@ +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/ports/backup_time_review_port.dart'; +import 'package:on_time_front/domain/ports/recovery_preclaim_cleanup_port.dart'; +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/ports/backup_file_import_port.dart'; +import 'package:on_time_front/core/backup/backup_file_import_port.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_delivery_cleanup.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/ports/recovery_restore_port.dart'; +import 'encrypted_pair_database.dart'; +import 'native_process_identity.dart'; +import 'pair_files.dart'; +import 'store_pair.dart'; + +final class PairRecoveryRequired implements Exception { + const PairRecoveryRequired(this.receipt); + final BackupRestoreReceipt receipt; +} + +final class RecoveryCandidate implements BackupRestoreInput { + RecoveryCandidate( + this.record, + this.preview, + this._release, + this.lease, + this.releaseProvisional, + this._impacts, + this.checkTimeAuthority, + ); + final void Function() checkTimeAuthority; + final PairRecord record; + final BackupProcessingLease lease; + final Future Function() releaseProvisional; + final Future Function({String? cursor}) _impacts; + @override + Future timeZoneImpacts({String? cursor}) => + _impacts(cursor: cursor); + @override + final BackupRestorePreview preview; + final Future Function() _release; + bool claimed = false; + @override + Future dispose() async { + if (!claimed) await _release(); + } +} + +/// Pre-DI only. The existing data gate and alarm owner serialize every mutable +/// boundary. The single flight lives beyond a disposed screen or a wait notice. +final class RecoveryRestoreService + implements + RecoveryRestorePort, + BackupTimeReviewPort, + RecoveryPreclaimCleanupPort { + RecoveryRestoreService( + this.files, { + BackupCrypto? crypto, + DateTime Function()? now, + BackupFileImportPort? importPort, + BackupProcessingOwner? processingOwner, + Future Function(BackupBudget)? ingestionFactory, + LocalDataOperationGate? gate, + AlarmOperationCoordinator? owner, + Future Function()? processIdentity, + Future Function()? cleanupPlatform, + required this.repairCutover, + Future Function(StorePair)? openPair, + Future Function(StorePair)? createPair, + Future Function(StorePair)? openStartupPair, + }) : _now = now ?? DateTime.now, + crypto = crypto ?? BackupCrypto(), + importPort = importPort ?? const NativeBackupFileImportPort(), + processingOwner = processingOwner ?? BackupProcessingOwner.shared, + ingestionFactory = ingestionFactory ?? BackupIngestionStore.create, + gate = gate ?? LocalDataOperationGate.shared, + owner = owner ?? AlarmOperationCoordinator.shared, + processIdentity = processIdentity ?? readNativeProcessIdentity, + _cleanupPlatform = cleanupPlatform, + openPair = openPair ?? ((pair) => openEncryptedPair(files, pair)), + openStartupPair = + openStartupPair ?? + openPair ?? + ((pair) => openEncryptedPair(files, pair, startupMigration: true)), + createPair = + createPair ?? + ((pair) => openEncryptedPair(files, pair, creating: true)); + final DateTime Function() _now; + final PairFiles files; + final BackupCrypto crypto; + final BackupFileImportPort importPort; + final BackupProcessingOwner processingOwner; + final Future Function(BackupBudget) ingestionFactory; + final LocalDataOperationGate gate; + final AlarmOperationCoordinator owner; + final Future Function() processIdentity; + final Future Function() repairCutover; + final Future Function()? _cleanupPlatform; + final Future Function(StorePair) openPair; + final Future Function(StorePair) createPair; + final Future Function(StorePair) openStartupPair; + Future? _flight; + _RecoverySourceAuthority? _preclaimCleanup; + int? _preclaimGeneration; + Future cleanup() => + (_cleanupPlatform ?? () => cleanupRestoreDeliveriesUnderOwner(owner))(); + + Future _requireNoReset() async { + if ((await owner.journal.read()).reset != ResetPhase.none) { + throw const LocalDataUnavailable(); + } + } + + Future<_RecoverySourceAuthority> _captureSource() => gate.run( + () => owner.cleanup(() async { + await _requireNoReset(); + if (_preclaimCleanup != null) { + throw const DataOperationException(DataOperationFailure.busy); + } + final original = await files.selected(); + return _RecoverySourceAuthority( + original, + await files.evidence(original), + await processIdentity(), + gate.generation, + ); + }), + ); + + Future _checkSource( + _RecoverySourceAuthority source, { + bool checkGeneration = true, + }) async { + if ((checkGeneration && source.generation != gate.generation) || + await files.selected() != source.original || + await processIdentity() != source.process || + await files.evidence(source.original) != source.evidence) { + throw const DataOperationException(DataOperationFailure.stalePreview); + } + } + + @override + Future selectForRestore(String password) async { + final lease = processingOwner.acquire()..beginOperation(); + BackupImportSource? source; + try { + final authority = await _captureSource(); + source = await importPort.select(lease: lease); + if (source == null) { + lease.release(); + return null; + } + return await _reviewStream(source.openRead(), password, lease, authority); + } catch (original) { + try { + await source?.close(); + } catch (cleanup) { + lease.retainCleanup(() async { + await source?.close(); + }); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); + } + } + + Future reviewBytes( + Uint8List bytes, + String password, + ) async { + final lease = processingOwner.acquire()..beginOperation(); + try { + final authority = await _captureSource(); + return await _reviewStream( + Stream.value(bytes), + password, + lease, + authority, + ); + } catch (_) { + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); + } + } + + Future _reviewStream( + Stream> bytes, + String password, + BackupProcessingLease lease, + _RecoverySourceAuthority authority, + ) => BackupAuthenticatedTimeReview.decrypt( + ciphertext: bytes, + password: password, + crypto: crypto, + budget: BackupBudget(lease: lease), + createStore: ingestionFactory, + now: _now, + ready: (content) => _prepareValidated( + content, + lease, + expectedSource: authority, + releaseOnFailure: false, + ), + ); + + @override + Future preview(String password) async { + final lease = processingOwner.acquire()..beginOperation(); + BackupImportSource? source; + try { + source = await importPort.select(lease: lease); + if (source == null) { + lease.release(); + return null; + } + return await _previewStream(source.openRead(), password, lease); + } catch (original) { + try { + await source?.close(); + } catch (cleanup) { + lease.retainCleanup(() async { + await source?.close(); + }); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); + } + } + + Future previewBytes( + Uint8List bytes, + String password, + ) async { + final lease = processingOwner.acquire()..beginOperation(); + try { + return await _previewStream(Stream.value(bytes), password, lease); + } catch (_) { + if (lease.phase != BackupProcessingPhase.cleanupPending) lease.release(); + rethrow; + } finally { + lease.endOperation(); + } + } + + Future _previewStream( + Stream> bytes, + String password, + BackupProcessingLease lease, + ) async { + final authority = await _captureSource(); + final rules = TimeZoneRules.loadedIdentity; + final content = await BackupValidatedIngestion.decrypt( + ciphertext: bytes, + password: password, + crypto: crypto, + budget: BackupBudget(lease: lease), + createStore: ingestionFactory, + nowUtc: _now().toUtc(), + ); + try { + content.establishTimeAuthority(rules); + } catch (_) { + try { + await content.release(); + } catch (_) { + lease.retainCleanup(content.release); + rethrow; + } + rethrow; + } + return _prepareValidated(content, lease, expectedSource: authority); + } + + Future _prepareValidated( + BackupValidatedIngestion content, + BackupProcessingLease lease, { + required _RecoverySourceAuthority expectedSource, + bool releaseOnFailure = true, + }) async { + void checkTime() { + if (!content.hasCurrentTimeAuthority(_now())) { + throw const DataOperationException(DataOperationFailure.stalePreview); + } + } + + Future releaseProvisional() async { + try { + await content.release(); + } catch (_) { + lease.retainCleanup(content.release); + rethrow; + } + lease.release(); + } + + try { + return await gate.run( + () => owner.cleanup(() async { + await _requireNoReset(); + await _checkSource(expectedSource); + checkTime(); + final prior = await files.readRecord(); + if (prior != null && + prior.stage != PairStage.ready && + prior.stage != PairStage.cancelled) { + throw const DataOperationException(DataOperationFailure.busy); + } + final original = await files.selected(); + final origin = await processIdentity(); + final target = StorePair.candidate(const Uuid().v4()); + var record = PairRecord( + target: target, + original: original, + originProcess: origin, + stage: PairStage.reserved, + ); + await files.writeRecord( + record, + ); // Reservation must precede any new key. + AppDatabase? db; + Future cleanupCandidate() async { + await db?.close(); + db = null; + await _discard(record); + } + + try { + final evidence = await files.evidence(original); + await files.keys.create(target); + await files.prepareDirectories(); + await files.database(target).create(exclusive: true); + await files.protect(files.database(target)); + final createdDb = await createPair(target); + db = createdDb; + await content.materialize(createdDb, pendingCleanup: true); + await content.validateReadBack(createdDb, pendingCleanup: true); + final row = await createdDb.select(createdDb.users).getSingle(); + final runtime = row.storeIncarnation; + if (runtime == null || + !row.restoreCleanupPending || + !row.rejectLegacyDelivery) { + throw const PairAuthorityUnavailable(); + } + await createdDb + .customSelect('PRAGMA wal_checkpoint(TRUNCATE)') + .get(); + await createdDb.close(); + db = null; + final reopenedDb = await openPair(target); + db = reopenedDb; + await content.validateReadBack(reopenedDb, pendingCleanup: true); + await reopenedDb.close(); + db = null; + await _checkSource(expectedSource); + checkTime(); + if (evidence != await files.evidence(original)) { + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + record = record.at( + PairStage.validated, + evidence: evidence, + runtime: runtime, + ); + await files.writeRecord(record); + await _checkSource(expectedSource); + checkTime(); + lease.check(); + lease.update(BackupProcessingPhase.preview); + return RecoveryCandidate( + record, + content.preview, + () async { + try { + await discard(target); + await content.release(); + } catch (error) { + lease.retainCleanup(() async { + await discard(target); + await content.release(); + }); + rethrow; + } + lease.release(); + }, + lease, + releaseProvisional, + content.timeZoneImpacts, + checkTime, + ); + } catch (original) { + try { + await cleanupCandidate(); + } catch (cleanup) { + // Retain the DB close + existing pair authority cleanup together. + lease.retainCleanup( + () => gate.run(() => owner.cleanup(cleanupCandidate)), + ); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + }), + ); + } catch (original) { + if (!releaseOnFailure) rethrow; + try { + await content.release(); + } catch (cleanup) { + lease.retainCleanup(content.release); + throw BackupProcessingCleanupFailure( + originalError: original, + cleanupError: cleanup, + ); + } + rethrow; + } + } + + Future discard(StorePair target) => gate.run( + () => owner.cleanup(() async { + final record = await files.readRecord(); + if (record == null || record.target != target || record.confirmed) return; + await _discard(record); + }), + ); + Future _discard(PairRecord record) async { + final active = await files.readManifest(); + if (active == record.target || record.confirmed) { + throw const PairAuthorityUnavailable(); + } + await files.removeFamily(record.target); + await files.keys.remove(record.target); + if (record.original.isLegacy && !await files.keys.hasHistory()) { + await files.removeRecord(); + } else { + await files.writeRecord(record.at(PairStage.cancelled)); + } + } + + @override + Future retryPreclaimCleanup(int generation) => gate.run( + () => owner.cleanup(() async { + final source = _preclaimCleanup; + if (source == null || + generation != _preclaimGeneration || + generation != gate.generation) { + throw const DataOperationException(DataOperationFailure.stalePreview); + } + await _requireNoReset(); + await _checkSource(source, checkGeneration: false); + final record = await files.readRecord(); + if (record?.confirmed == true) throw const PairAuthorityUnavailable(); + try { + await cleanup(); + } catch (_) { + throw DataOperationException( + DataOperationFailure.stalePreview, + followUpPending: true, + generation: generation, + ); + } + await _checkSource(source, checkGeneration: false); + _preclaimCleanup = null; + _preclaimGeneration = null; + }), + ); + + @override + Future activate(BackupRestoreInput input) { + if (_flight != null) return _flight!; + if (input is! RecoveryCandidate) { + throw const DataOperationException(DataOperationFailure.invalidBackup); + } + input.claimed = true; + input.lease.beginOperation(); + input.lease.cancellationAllowed = false; + input.lease.update(BackupProcessingPhase.applying); + return _flight = gate + .run( + () => owner.cleanup(() async { + await _requireNoReset(); + var record = await files.readRecord(); + if (record == null || + record.target != input.record.target || + record.stage != PairStage.validated || + record.originProcess != await processIdentity() || + await files.selected() != record.original || + record.originalEvidence != + await files.evidence(record.original)) { + input.claimed = false; + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + if (await originalPairReadable(files, record.original)) { + throw const RecoveryOriginalAvailable(); + } + if (input.preview.cutoffLiteral != null) { + // This local installation marker describes this commit, never the + // unknown instant of an offsetless legacy backup cutoff. + final candidateDb = await openPair(record.target); + try { + final marker = _now().toUtc(); + await (candidateDb.update( + candidateDb.users, + )..where((u) => u.id.equals(localProfileId))).write( + UsersCompanion( + firstDurableDataAt: Value(marker), + lastDurableDataAt: Value(marker), + ), + ); + } finally { + try { + await candidateDb.close(); + } catch (_) { + input.lease.retainCleanup(candidateDb.close); + rethrow; + } + } + } + await _verify(record, requirePending: true); + input.checkTimeAuthority(); + final preclaim = _RecoverySourceAuthority( + record.original, + record.originalEvidence!, + record.originProcess, + gate.generation, + ); + _preclaimCleanup = preclaim; + _preclaimGeneration = gate.generation; + try { + await cleanup(); + await _requireNoReset(); + await _checkSource(preclaim); + input.checkTimeAuthority(); + final current = await files.readRecord(); + if (current == null || + current.target != record.target || + current.stage != PairStage.validated) { + throw const DataOperationException( + DataOperationFailure.stalePreview, + ); + } + input.checkTimeAuthority(); + } catch (_) { + throw DataOperationException( + DataOperationFailure.stalePreview, + followUpPending: true, + generation: gate.generation, + ); + } + record = record.at(PairStage.confirmed); + // From this intent onward an uncertain write cannot reopen normal use. + gate.setRecoveryPending(true); + try { + await files.writeRecord(record); + _preclaimCleanup = null; + _preclaimGeneration = null; + await files.keys.markHistory(); + await files.publish( + record.target, + expected: record.original.isLegacy ? null : record.original, + ); + await files.writeRecord(record.at(PairStage.activated)); + return _receipt(RecoveryFollowUp.awaitingNewProcess); + } catch (_) { + try { + final durable = await files.readRecord(); + if (durable != null && + durable.target == record.target && + !durable.confirmed && + await files.readManifest() != record.target && + !await files.keys.hasHistory()) { + input.claimed = false; + gate.setRecoveryPending(false); + throw DataOperationException( + DataOperationFailure.stalePreview, + followUpPending: true, + generation: gate.generation, + ); + } + } on DataOperationException { + rethrow; + } catch (_) { + /* Retain uncertain ownership. */ + } + _preclaimCleanup = null; + _preclaimGeneration = null; + return _observe(record); + } + }), + replacesData: true, + validateReplacement: () async { + if (_preclaimCleanup != null) { + throw const DataOperationException(DataOperationFailure.busy); + } + input.checkTimeAuthority(); + }, + ) + .whenComplete(() async { + if (gate.isRecoveryPending) { + try { + await input.releaseProvisional(); + } catch (_) { + /* Separate cleanup remains owned and retryable. */ + } + } + if (!gate.isRecoveryPending) input.claimed = false; + _flight = null; + input.lease.endOperation(); + }); + } + + BackupRestoreReceipt _receipt(RecoveryFollowUp phase) => + BackupRestoreReceipt.recovery(generation: gate.generation, phase: phase); + Future _observe(PairRecord record) async { + try { + final durable = await files.readRecord(); + if (durable == null || durable.target != record.target) { + throw const PairAuthorityUnavailable(); + } + record = durable; + final current = await files.readManifest(); + await files.checkIntent(record); + if (current == record.target) { + return _receipt(RecoveryFollowUp.awaitingNewProcess); + } + // Once the history marker is written a missing first manifest is an + // explicit interrupted-publication case, never implicit legacy authority. + if (current == record.original && + current != null && + (record.stage == PairStage.confirmed || + record.stage == PairStage.aborting) && + record.originalEvidence == await files.evidence(record.original)) { + await files.checkIntent(record, forAbort: true); + return BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: gate.generation, + followUpPending: true, + ); + } + } catch (_) { + /* Unknown is not false. */ + } + return BackupRestoreReceipt.uncertain(generation: gate.generation); + } + + Future _verify( + PairRecord record, { + required bool requirePending, + bool currentRuntime = false, + StorePair? pair, + bool startupMigration = false, + }) async { + final db = await (startupMigration ? openStartupPair : openPair)( + pair ?? record.target, + ); + try { + final integrity = await db.customSelect('PRAGMA quick_check').getSingle(); + final foreign = await db.customSelect('PRAGMA foreign_key_check').get(); + final row = await db.select(db.users).getSingle(); + if (integrity.data.values.single != 'ok' || + foreign.isNotEmpty || + row.id != localProfileId || + row.storeIncarnation == null || + !RegExp(r'^[a-f0-9]{32}$').hasMatch(row.storeIncarnation!) || + (!currentRuntime && row.storeIncarnation != record.runtimeIdentity) || + !row.rejectLegacyDelivery || + (requirePending && !row.restoreCleanupPending)) { + throw const PairAuthorityUnavailable(); + } + } finally { + await db.close(); + } + } + + @override + Future abort() => _flight ??= owner + .cleanup(() async { + await _requireNoReset(); + gate.setRecoveryPending(true); + final record = await files.readRecord(); + if (record == null) { + return BackupRestoreReceipt.uncertain(generation: gate.generation); + } + try { + return await _abort(record); + } catch (_) { + return _observe(record); + } + }) + .whenComplete(() => _flight = null); + Future _abort(PairRecord record) async { + if (record.stage == PairStage.cancelled) { + if (await files.selected() != record.original || + await files.keys.raw(record.target) != null || + await files.exists(files.database(record.target).path)) { + throw const PairAuthorityUnavailable(); + } + gate.setRecoveryPending(false); + return BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: gate.generation, + ); + } + if (record.stage != PairStage.confirmed && + record.stage != PairStage.aborting) { + throw const PairAuthorityUnavailable(); + } + await files.checkIntent(record, forAbort: true); + if (record.original.isLegacy || + await files.readManifest() != record.original || + record.originalEvidence != await files.evidence(record.original)) { + throw const PairAuthorityUnavailable(); + } + final aborting = record.at(PairStage.aborting); + if (record.stage != PairStage.aborting) await files.writeRecord(aborting); + await files.removeOwnedPublication(aborting); + await files.removeFamily(record.target); + await files.keys.remove(record.target); + await files.checkpoint?.call('abort.key.removed'); + await files.writeRecord(record.at(PairStage.cancelled)); + gate.setRecoveryPending(false); + return BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: gate.generation, + ); + } + + /// Startup inspects authority but never publishes a missing manifest by itself. + Future prepareStartup() async { + try { + await _prepareStartup(); + } on PairRecoveryRequired { + rethrow; + } on RestoreStoreUnavailable { + rethrow; + } catch (_) { + gate.setRecoveryPending(true); + throw PairRecoveryRequired( + BackupRestoreReceipt.uncertain(generation: gate.generation), + ); + } + } + + Future _prepareStartup() async { + await _requireNoReset(); + final state = await files.readRecord(); + if (state == null) { + await files.selected(); + return; + } + if (!state.confirmed && state.stage != PairStage.cancelled) { + await owner.cleanup(() => _discard(state)); + await files.selected(); + gate.setRecoveryPending(false); + final remaining = await files.readRecord(); + if (remaining != null) await _prepareCurrent(remaining); + return; + } + if (state.stage == PairStage.ready || state.stage == PairStage.cancelled) { + await _prepareCurrent(state); + return; + } + final receipt = await resume(allowPublish: false); + if (receipt.followUpPending || state.stage == PairStage.aborting) { + throw PairRecoveryRequired(receipt); + } + } + + Future _prepareCurrent(PairRecord state) => owner.cleanup(() async { + await _requireNoReset(); + final pair = state.stage == PairStage.ready ? state.target : state.original; + // Selection errors remain unknown authority, separate from a selected + // completed store that has become damaged and can accept a new recovery. + if (await files.selected() != pair) throw const PairAuthorityUnavailable(); + try { + await _verify( + state, + requirePending: false, + currentRuntime: true, + pair: pair, + startupMigration: true, + ); + } catch (cause) { + gate.setRecoveryPending(false); + throw RestoreStoreUnavailable(cause); + } + gate.setRecoveryPending(true); + try { + final db = await openStartupPair(pair); + try { + await RestoreRuntimeIdentity.shared.cleanup( + db, + gate, + cleanupPlatform: cleanup, + releaseGate: false, + ); + } finally { + await db.close(); + } + await _verify( + state, + requirePending: false, + currentRuntime: true, + pair: pair, + startupMigration: true, + ); + await repairCutover(); + gate.setRecoveryPending(false); + } catch (_) { + throw PairRecoveryRequired(_receipt(RecoveryFollowUp.cleanupPending)); + } + }); + @override + Future resume({bool allowPublish = true}) { + return _flight ??= owner + .cleanup(() async { + await _requireNoReset(); + gate.setRecoveryPending(true); + final record = await files.readRecord(); + if (record == null) throw const PairAuthorityUnavailable(); + if (!record.confirmed) { + if (record.stage != PairStage.cancelled) await _discard(record); + gate.setRecoveryPending(false); + return BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: gate.generation, + ); + } + var phase = RecoveryFollowUp.verifyingPair; + try { + if (record.stage == PairStage.aborting) return _abort(record); + if (record.stage == PairStage.ready) { + if (await files.selected() != record.target) { + throw const PairAuthorityUnavailable(); + } + await _verify( + record, + requirePending: false, + currentRuntime: true, + ); + final db = await openPair(record.target); + try { + await RestoreRuntimeIdentity.shared.cleanup( + db, + gate, + cleanupPlatform: cleanup, + releaseGate: false, + ); + } finally { + await db.close(); + } + await repairCutover(); + gate.setRecoveryPending(false); + return BackupRestoreReceipt( + disposition: BackupCommitDisposition.committed, + generation: gate.generation, + ); + } + await files.checkIntent(record); + final process = await processIdentity(); + var active = await files.readManifest(); + if (active == null && + allowPublish && + process != record.originProcess && + record.stage != PairStage.ready && + record.originalEvidence == + await files.evidence(record.original)) { + await _verify(record, requirePending: true); + if (!await files.keys.hasHistory()) { + if (!record.original.isLegacy || + record.stage != PairStage.confirmed) { + throw const PairAuthorityUnavailable(); + } + await files.checkIntent(record, firstActivation: true); + await files.keys.markHistory(); + } + // Explicit Q5 exception: same confirmed target and same bytes only. + await files.publish(record.target); + active = await files.readManifest(); + } + if (active != record.target) return _observe(record); + if (process == record.originProcess) { + return _receipt(RecoveryFollowUp.awaitingNewProcess); + } + await _verify(record, requirePending: false); + phase = RecoveryFollowUp.cleanupPending; + await repairCutover(); + final db = await openPair(record.target); + try { + await RestoreRuntimeIdentity.shared.cleanup( + db, + gate, + cleanupPlatform: cleanup, + releaseGate: false, + ); + } finally { + await db.close(); + } + // Cleanup can clear its DB pending flag before the record write fails. + // Retry verifies the identity even when that completed stage is observed. + await files.writeRecord(record.at(PairStage.verified)); + await files.removeFamily(record.original); + await files.keys.remove(record.original); + await files.writeRecord(record.at(PairStage.ready)); + gate.setRecoveryPending(false); + return BackupRestoreReceipt( + disposition: BackupCommitDisposition.committed, + generation: gate.generation, + ); + } catch (_) { + final observed = await _observe(record); + if (observed.disposition != BackupCommitDisposition.committed) { + return observed; + } + return _receipt(phase); + } + }) + .whenComplete(() => _flight = null); + } +} + +final class _RecoverySourceAuthority { + const _RecoverySourceAuthority( + this.original, + this.evidence, + this.process, + this.generation, + ); + final StorePair original; + final String evidence; + final String process; + final int generation; +} diff --git a/lib/core/database/recovery/store_pair.dart b/lib/core/database/recovery/store_pair.dart new file mode 100644 index 00000000..1bdd7bcf --- /dev/null +++ b/lib/core/database/recovery/store_pair.dart @@ -0,0 +1,116 @@ +/// Paths and key slots are derived from these allowlisted IDs, never supplied by +/// a backup or copied verbatim out of a recovery record. +final class StorePair { + const StorePair.legacy() : id = 'legacy'; + StorePair.candidate(this.id) { + if (!isCandidateId(id)) throw const PairAuthorityUnavailable(); + } + final String id; + bool get isLegacy => id == 'legacy'; + static bool isCandidateId(String value) => RegExp( + r'^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$', + ).hasMatch(value); + static StorePair decode(Object? value) => value == 'legacy' + ? const StorePair.legacy() + : value is String + ? StorePair.candidate(value) + : throw const PairAuthorityUnavailable(); + @override + bool operator ==(Object other) => other is StorePair && other.id == id; + @override + int get hashCode => id.hashCode; +} + +final class PairAuthorityUnavailable implements Exception { + const PairAuthorityUnavailable(); + @override + String toString() => 'Store selection requires recovery'; +} + +enum PairStage { + reserved, + validated, + confirmed, + activated, + verified, + ready, + aborting, + cancelled, +} + +final class PairRecord { + const PairRecord({ + required this.target, + required this.original, + required this.originProcess, + required this.stage, + this.originalEvidence, + this.runtimeIdentity, + }); + final StorePair target; + final StorePair original; + final String originProcess; + final PairStage stage; + final String? originalEvidence; + final String? runtimeIdentity; + bool get confirmed => + stage != PairStage.cancelled && stage.index >= PairStage.confirmed.index; + Map encode() => { + 'version': 1, + 'target': target.id, + 'original': original.id, + 'originProcess': originProcess, + 'stage': stage.name, + 'originalEvidence': originalEvidence, + 'runtimeIdentity': runtimeIdentity, + }; + PairRecord at(PairStage next, {String? evidence, String? runtime}) => + PairRecord( + target: target, + original: original, + originProcess: originProcess, + stage: next, + originalEvidence: evidence ?? originalEvidence, + runtimeIdentity: runtime ?? runtimeIdentity, + ); + static PairRecord decode(Object? raw) { + try { + if (raw is! Map || + raw.length != 7 || + raw['version'] != 1) { + throw const PairAuthorityUnavailable(); + } + final target = StorePair.decode(raw['target']); + final original = StorePair.decode(raw['original']); + final process = raw['originProcess']; + final evidence = raw['originalEvidence']; + final runtime = raw['runtimeIdentity']; + final stage = PairStage.values.byName(raw['stage'] as String); + if (target.isLegacy || + target == original || + process is! String || + !StorePair.isCandidateId(process) || + (evidence != null && + (evidence is! String || + !RegExp(r'^[a-f0-9]{64}$').hasMatch(evidence))) || + (runtime != null && + (runtime is! String || + !RegExp(r'^[a-f0-9]{32}$').hasMatch(runtime))) || + (stage != PairStage.reserved && + stage != PairStage.cancelled && + (evidence == null || runtime == null))) { + throw const PairAuthorityUnavailable(); + } + return PairRecord( + target: target, + original: original, + originProcess: process, + stage: stage, + originalEvidence: evidence as String?, + runtimeIdentity: runtime as String?, + ); + } catch (_) { + throw const PairAuthorityUnavailable(); + } + } +} diff --git a/lib/core/database/restore_delivery_cleanup.dart b/lib/core/database/restore_delivery_cleanup.dart new file mode 100644 index 00000000..54ffc193 --- /dev/null +++ b/lib/core/database/restore_delivery_cleanup.dart @@ -0,0 +1,72 @@ +import 'package:on_time_front/core/database/local_reset_actions.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_registration_cleanup.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/data/data_sources/alarm_registry_local_data_source.dart'; +import 'package:on_time_front/data/repositories/alarm_registry_repository_impl.dart'; +import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; + +/// Restore reuses only D03's app-owned delivery and launch cleanup. It never +/// creates a reset intent or invokes database/preferences/key/credential steps. +Future cleanupRestoreDeliveries( + AlarmOperationCoordinator owner, { + DeviceLocalResetActions? actions, + AlarmRegistrationCleanup? registrationCleanup, +}) => owner.cleanup( + () => cleanupRestoreDeliveriesUnderOwner( + owner, + actions: actions, + registrationCleanup: registrationCleanup, + ), +); + +/// For an existing replacement owner only; never enqueue recursively. +Future cleanupRestoreDeliveriesUnderOwner( + AlarmOperationCoordinator owner, { + DeviceLocalResetActions? actions, + AlarmRegistrationCleanup? registrationCleanup, +}) async { + final target = actions ?? DeviceLocalResetActions(); + final cleanup = + registrationCleanup ?? + AlarmRegistrationCleanup( + AlarmRegistryRepositoryImpl( + localDataSource: AlarmRegistryLocalDataSourceImpl(), + ), + AlarmSchedulerService(), + FallbackAlarmNotificationServiceImpl(), + owner, + ); + // Await real old provider Futures, including returned failures. Global OS + // absence can subsequently resolve uncertainty; a timeout cannot do so. + await cleanup.cancelMatchingReport(); + await target.perform(ResetStep.deliveries); + await target.perform(ResetStep.launch); + var state = await owner.journal.read(); + if (state.reset != ResetPhase.none) throw const AlarmCleanupIncomplete(); + if (target.allProvidersConfirmedEmpty) { + final registry = cleanup.registry; + for (final entry in state.ownership.values.toList()) { + await owner.confirmedCancelled(registry, entry.record); + } + await owner.replaceRecords(registry, []); + if (registry is RecoverableAlarmOwnershipIntegrity) { + await (registry as RecoverableAlarmOwnershipIntegrity) + .clearResolvedOwnership(); + } + state = await owner.journal.read(); + state.unknownOwnership = false; + await owner.journal.save(state); + } + // Android cannot globally enumerate AlarmManager: known per-ID cancellations + // suffice only when the independent journal has no unknown ownership. + final remaining = await owner.loadRecords(cleanup.registry); + state = await owner.journal.read(); + if (remaining.isNotEmpty || + state.ownership.isNotEmpty || + state.unknownOwnership) { + throw const AlarmCleanupIncomplete(); + } +} diff --git a/lib/core/database/restore_runtime_identity.dart b/lib/core/database/restore_runtime_identity.dart new file mode 100644 index 00000000..46782b1a --- /dev/null +++ b/lib/core/database/restore_runtime_identity.dart @@ -0,0 +1,125 @@ +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:drift/drift.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Cache of installation-local DB authority, loaded before runtime consumers. +/// The backup revision is deliberately not used as a replacement epoch. +final class RestoreRuntimeIdentity { + static final shared = RestoreRuntimeIdentity(); + String? storeIncarnation; + bool rejectLegacy = false; + bool _pending = false; + Object _loadOwner = Object(); + + bool get pending => _pending; + set pending(bool value) { + // A replacement hold supersedes every read that started before it. + _loadOwner = Object(); + _pending = value; + } + + bool accepts(Object? identity) => + !pending && + (identity == null ? !rejectLegacy : identity == storeIncarnation); + + Future load(AppDatabase database) async { + final owner = Object(); + _loadOwner = owner; + final row = await (database.select( + database.users, + )..where((t) => t.id.equals(localProfileId))).getSingleOrNull(); + if (!identical(owner, _loadOwner)) return; + storeIncarnation = row?.storeIncarnation; + rejectLegacy = row?.rejectLegacyDelivery ?? false; + _pending = row?.restoreCleanupPending ?? false; + } + + /// Pre-DI distinguishes an unreadable store from a known restore receipt. + Future prepareStartup( + AppDatabase database, + LocalDataOperationGate gate, { + required Future Function() cleanupPlatform, + }) async { + try { + await load(database); + } catch (cause) { + throw RestoreStoreUnavailable(cause); + } + if (!pending) { + gate.setRecoveryPending(false); + return; + } + try { + await cleanup(database, gate, cleanupPlatform: cleanupPlatform); + } catch (cause) { + throw RestoreRecoveryRequired(cause); + } + } + + Future cleanup( + AppDatabase database, + LocalDataOperationGate gate, { + required Future Function() cleanupPlatform, + bool releaseGate = true, + }) async { + await load(database); + gate.setRecoveryPending(!releaseGate || pending); + if (!pending) return; + final identity = storeIncarnation; + if (identity == null) throw StateError('Restore identity unavailable'); + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + for (final key + in prefs + .getKeys() + .where( + (key) => + key.startsWith('preparation_with_time_') || + key.startsWith('early_start_session_'), + ) + .toList()) { + if (!await prefs.remove(key)) { + throw StateError('Restore runtime cleanup pending'); + } + } + await prefs.reload(); + if (prefs.getKeys().any( + (key) => + key.startsWith('preparation_with_time_') || + key.startsWith('early_start_session_'), + )) { + throw StateError('Restore runtime cleanup unconfirmed'); + } + await cleanupPlatform(); + await database.transaction(() async { + final changed = + await (database.update(database.users)..where( + (t) => + t.id.equals(localProfileId) & + t.storeIncarnation.equals(identity), + )) + .write(const UsersCompanion(restoreCleanupPending: Value(false))); + if (changed != 1) throw StateError('Restore identity changed'); + final row = await (database.select( + database.users, + )..where((t) => t.id.equals(localProfileId))).getSingle(); + if (row.storeIncarnation != identity || row.restoreCleanupPending) { + throw StateError('Restore cleanup commit unconfirmed'); + } + }); + await load(database); + gate.setRecoveryPending(!releaseGate || pending); + } +} + +final class RestoreRecoveryRequired implements Exception { + const RestoreRecoveryRequired(this.cause); + final Object cause; +} + +final class RestoreStoreUnavailable implements Exception { + const RestoreStoreUnavailable(this.cause); + final Object cause; +} diff --git a/lib/core/database/schema_contract.dart b/lib/core/database/schema_contract.dart new file mode 100644 index 00000000..0fbab45f --- /dev/null +++ b/lib/core/database/schema_contract.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'schema_contracts.g.dart'; + +/// Immutable release contracts, embedded by build_runner without runtime I/O. +final class DatabaseSchemaContract { + DatabaseSchemaContract._(); + static final Map _data = + jsonDecode(databaseSchemaContractsJson) as Map; + static int get current => _data['current'] as int; + static List get legacySupported => + List.from(_data['legacySupported'] as List); + static List get pairSupported => + List.from(_data['pairSupported'] as List); + static const equality = DeepCollectionEquality(); + static const objectsQuery = + "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT GLOB 'sqlite_*' AND sql IS NOT NULL ORDER BY type,name"; + + static void validate(int version, List> objects) { + final snapshot = (_data['snapshots'] as List) + .cast>() + .where((s) => s['version'] == version) + .singleOrNull; + if (snapshot == null) throw const UnsupportedDatabaseSchema(); + final expected = _shape( + (snapshot['objects'] as List) + .map((o) => Map.from(o as Map)) + .toList(), + ); + if (!equality.equals(_shape(objects), expected)) { + throw const UnsupportedDatabaseSchema(); + } + } + + static Map _tables(int version) => + ((_data['snapshots'] as List).cast>().singleWhere( + (s) => s['version'] == version, + )['tables'] + as Map); + + static void validateTableMetadata( + int version, + String table, + List> columns, + List> foreignKeys, + List> indexes, + ) { + final expected = _tables(version)[table] as Map?; + if (expected == null) throw const UnsupportedDatabaseSchema(); + List> columnShape(List> rows) => + rows + .map( + (r) => { + for (final k in [ + 'name', + 'type', + 'notnull', + 'dflt_value', + 'pk', + 'hidden', + ]) + k: r[k], + }, + ) + .toList() + ..sort( + (a, b) => (a['name'] as String).compareTo(b['name'] as String), + ); + List foreignShape(List> rows) { + final groups = >>{}; + for (final row in rows) { + (groups[row['id']] ??= []).add({ + for (final k in [ + 'seq', + 'table', + 'from', + 'to', + 'on_update', + 'on_delete', + 'match', + ]) + k: row[k], + }); + } + return groups.values.map((group) { + group.sort((a, b) => (a['seq'] as int).compareTo(b['seq'] as int)); + return jsonEncode(group); + }).toList()..sort(); + } + + List> castRows(Object? value) => (value as List) + .map((r) => Map.from(r as Map)) + .toList(); + List indexShape(List> rows) => + rows + .map( + (r) => jsonEncode({ + 'unique': r['unique'], + 'origin': r['origin'], + 'partial': r['partial'], + 'columns': castRows(r['columns']) + .map( + (c) => { + for (final k in [ + 'seqno', + 'name', + 'desc', + 'coll', + 'key', + ]) + k: c[k], + }, + ) + .toList(), + }), + ) + .toList() + ..sort(); + if (!equality.equals( + columnShape(columns), + columnShape(castRows(expected['columns'])), + ) || + !equality.equals( + foreignShape(foreignKeys), + foreignShape(castRows(expected['foreignKeys'])), + ) || + !equality.equals( + indexShape(indexes), + indexShape(castRows(expected['indexes'])), + )) { + throw const UnsupportedDatabaseSchema(); + } + } + + static List _shape(List> objects) { + final rows = >[]; + for (final object in objects) { + final type = object['type']; + final name = object['name']; + final table = object['tbl_name']; + final sql = object['sql']; + if (type is! String || + name is! String || + table is! String || + sql is! String) { + throw const UnsupportedDatabaseSchema(); + } + final tokens = sqlTokens(sql); + Object body = tokens; + if (type == 'table') { + final start = tokens.indexOf('('); + if (start < 0 || tokens.last != ')') { + throw const UnsupportedDatabaseSchema(); + } + // ALTER TABLE appends columns after the table's PK clause. Column + // ordinals do not define this application's explicit-column queries. + // Order *inside* PK/FK/UNIQUE/CHECK expressions remains significant. + final clauses = []; + var depth = 0; + var currentClause = []; + for (final token in tokens.sublist(start + 1, tokens.length - 1)) { + if (token == ',' && depth == 0) { + clauses.add(jsonEncode(currentClause)); + currentClause = []; + } else { + if (token == '(') depth++; + if (token == ')') depth--; + if (depth < 0) throw const UnsupportedDatabaseSchema(); + currentClause.add(token); + } + } + if (depth != 0) throw const UnsupportedDatabaseSchema(); + clauses.add(jsonEncode(currentClause)); + clauses.sort(); + body = [tokens.sublist(0, start), clauses]; + } + rows.add([type, name, table, body]); + } + rows.sort((a, b) => '${a[0]}:${a[1]}'.compareTo('${b[0]}:${b[1]}')); + return rows; + } +} + +final class UnsupportedDatabaseSchema implements Exception { + const UnsupportedDatabaseSchema(); + @override + String toString() => 'Local database schema requires recovery'; +} + +/// A lexical comparison, not whitespace stripping. String literal contents and +/// expression/group order are retained. Uncertain syntax is refused. +List sqlTokens(String sql) { + final result = []; + var i = 0; + while (i < sql.length) { + final char = sql[i]; + if (RegExp(r'\s').hasMatch(char)) { + i++; + continue; + } + if (i + 1 < sql.length && sql.substring(i, i + 2) == '--') { + final end = sql.indexOf('\n', i + 2); + i = end < 0 ? sql.length : end + 1; + continue; + } + if (i + 1 < sql.length && sql.substring(i, i + 2) == '/*') { + final end = sql.indexOf('*/', i + 2); + if (end < 0) throw const UnsupportedDatabaseSchema(); + i = end + 2; + continue; + } + if (char == "'" || char == '"' || char == '`' || char == '[') { + final close = char == '[' ? ']' : char; + var value = ''; + var ended = false; + i++; + while (i < sql.length) { + if (sql[i] == close) { + if (char != '[' && i + 1 < sql.length && sql[i + 1] == close) { + value += close; + i += 2; + continue; + } + i++; + ended = true; + break; + } + value += sql[i++]; + } + if (!ended) throw const UnsupportedDatabaseSchema(); + result.add(char == "'" ? 's:$value' : 'q:$value'); + continue; + } + if (RegExp(r'[A-Za-z_]').hasMatch(char)) { + final begin = i++; + while (i < sql.length && RegExp(r'[A-Za-z_0-9]').hasMatch(sql[i])) { + i++; + } + result.add('w:${sql.substring(begin, i).toLowerCase()}'); + continue; + } + if (RegExp(r'[0-9]').hasMatch(char)) { + final begin = i++; + while (i < sql.length && RegExp(r'[0-9.]').hasMatch(sql[i])) { + i++; + } + result.add('n:${sql.substring(begin, i)}'); + continue; + } + if ('(),;=<>!+-*/.%|&~'.contains(char)) { + if (char != ';' || i != sql.length - 1) result.add(char); + i++; + continue; + } + throw const UnsupportedDatabaseSchema(); + } + return result; +} diff --git a/lib/core/database/schema_contracts.schema.json b/lib/core/database/schema_contracts.schema.json new file mode 100644 index 00000000..29222311 --- /dev/null +++ b/lib/core/database/schema_contracts.schema.json @@ -0,0 +1,5591 @@ +{ + "format": 1, + "current": 4, + "legacySupported": [ + 1, + 2, + 3, + 4 + ], + "pairSupported": [ + 4 + ], + "snapshots": [ + { + "version": 1, + "sourceCommit": "ccc6272bde15937a87dd577157b3330593718759", + "ddlPath": "test/fixtures/database/schema_v1.sql", + "ddlSha256": "782cd3a3cfdca56c67f896a7fbdfa9791a349f77b2b65e21c7f0c0f2ce4a121d", + "engine": "3.51.0", + "objects": [ + { + "type": "index", + "name": "preparation_schedules_next_preparation_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_next_preparation_id_idx ON preparation_schedules (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_schedules_schedule_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_schedule_id_idx ON preparation_schedules (schedule_id)" + }, + { + "type": "index", + "name": "preparation_template_steps_template_id_idx", + "tbl_name": "preparation_template_steps", + "sql": "CREATE INDEX preparation_template_steps_template_id_idx ON preparation_template_steps (template_id)" + }, + { + "type": "index", + "name": "preparation_users_next_preparation_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_next_preparation_id_idx ON preparation_users (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_users_user_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_user_id_idx ON preparation_users (user_id)" + }, + { + "type": "index", + "name": "schedules_place_id_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_place_id_idx ON schedules (place_id)" + }, + { + "type": "index", + "name": "schedules_schedule_time_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_schedule_time_idx ON schedules (schedule_time)" + }, + { + "type": "table", + "name": "places", + "tbl_name": "places", + "sql": "CREATE TABLE \"places\" (\"id\" TEXT NOT NULL, \"place_name\" TEXT NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_schedules", + "tbl_name": "preparation_schedules", + "sql": "CREATE TABLE \"preparation_schedules\" (\"id\" TEXT NOT NULL, \"schedule_id\" TEXT NOT NULL REFERENCES schedules (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_schedules (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_template_steps", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TABLE \"preparation_template_steps\" (\"id\" TEXT NOT NULL, \"template_id\" TEXT NOT NULL REFERENCES preparation_templates (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"position\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_templates", + "tbl_name": "preparation_templates", + "sql": "CREATE TABLE \"preparation_templates\" (\"id\" TEXT NOT NULL, \"template_name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_users", + "tbl_name": "preparation_users", + "sql": "CREATE TABLE \"preparation_users\" (\"id\" TEXT NOT NULL, \"user_id\" TEXT NOT NULL REFERENCES users (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_users (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "schedules", + "tbl_name": "schedules", + "sql": "CREATE TABLE \"schedules\" (\"id\" TEXT NOT NULL, \"place_id\" TEXT NOT NULL REFERENCES places (id), \"schedule_name\" TEXT NOT NULL, \"time_zone_id\" TEXT NOT NULL DEFAULT 'UTC', \"occurrence_offset_seconds\" INTEGER NULL, \"schedule_time\" TEXT NOT NULL, \"move_time\" INTEGER NOT NULL, \"is_changed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_changed\" IN (0, 1)), \"is_started\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_started\" IN (0, 1)), \"schedule_spare_time\" INTEGER NULL, \"schedule_note\" TEXT NULL, \"lateness_time\" INTEGER NOT NULL DEFAULT -1, \"done_status\" TEXT NOT NULL DEFAULT 'notEnded', \"started_at\" INTEGER NULL, \"finished_at\" INTEGER NULL, \"preparation_mode\" TEXT NULL, \"preparation_template_id\" TEXT NULL, \"preparation_template_name\" TEXT NULL, \"preparation_template_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_template_deleted\" IN (0, 1)), \"preparation_frozen\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_frozen\" IN (0, 1)), \"score_contribution_recorded\" INTEGER NOT NULL DEFAULT 0 CHECK (\"score_contribution_recorded\" IN (0, 1)), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "users", + "tbl_name": "users", + "sql": "CREATE TABLE \"users\" (\"id\" TEXT NOT NULL, \"spare_time\" INTEGER NOT NULL, \"note\" TEXT NOT NULL, \"is_onboarding_completed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_onboarding_completed\" IN (0, 1)), \"eligible_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"on_time_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"alarms_enabled\" INTEGER NOT NULL DEFAULT 1 CHECK (\"alarms_enabled\" IN (0, 1)), \"alarm_offset_minutes\" INTEGER NOT NULL DEFAULT 0, \"detailed_notification_content\" INTEGER NOT NULL DEFAULT 0 CHECK (\"detailed_notification_content\" IN (0, 1)), \"data_revision\" INTEGER NOT NULL DEFAULT 0, \"last_exported_revision\" INTEGER NULL, \"last_exported_at\" INTEGER NULL, \"first_durable_data_at\" INTEGER NULL, \"last_durable_data_at\" INTEGER NULL, PRIMARY KEY (\"id\"))" + } + ], + "tables": { + "places": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_schedules": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "schedule_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_schedules", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "schedules", + "from": "schedule_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_template_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_templates", + "from": "template_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "template_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_templates": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "updated_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_users": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "user_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_users", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "users", + "from": "user_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "user_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "schedules": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "time_zone_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'UTC'", + "pk": 0, + "hidden": 0 + }, + { + "name": "occurrence_offset_seconds", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_time", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "move_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_changed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "is_started", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_spare_time", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_note", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "lateness_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "-1", + "pk": 0, + "hidden": 0 + }, + { + "name": "done_status", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'notEnded'", + "pk": 0, + "hidden": 0 + }, + { + "name": "started_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "finished_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_mode", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_deleted", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_frozen", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "score_contribution_recorded", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "places", + "from": "place_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_time", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "place_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "users": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "spare_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "note", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_onboarding_completed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "eligible_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "on_time_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarms_enabled", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "1", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarm_offset_minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "detailed_notification_content", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "data_revision", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_revision", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "first_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + } + } + }, + { + "version": 2, + "sourceCommit": "4529d9f84e911b1ee2fbb42af09698a0c27cc9f2", + "ddlPath": "test/fixtures/database/schema_v2.sql", + "ddlSha256": "a7ec3c57f1c86a58ef45c3417abe187fa4e44a0be87e3861a7182cb3ab02d7c5", + "engine": "3.51.0", + "objects": [ + { + "type": "index", + "name": "preparation_schedules_next_preparation_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_next_preparation_id_idx ON preparation_schedules (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_schedules_schedule_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_schedule_id_idx ON preparation_schedules (schedule_id)" + }, + { + "type": "index", + "name": "preparation_template_steps_template_id_idx", + "tbl_name": "preparation_template_steps", + "sql": "CREATE INDEX preparation_template_steps_template_id_idx ON preparation_template_steps (template_id)" + }, + { + "type": "index", + "name": "preparation_users_next_preparation_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_next_preparation_id_idx ON preparation_users (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_users_user_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_user_id_idx ON preparation_users (user_id)" + }, + { + "type": "index", + "name": "schedules_place_id_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_place_id_idx ON schedules (place_id)" + }, + { + "type": "index", + "name": "schedules_schedule_time_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_schedule_time_idx ON schedules (schedule_time)" + }, + { + "type": "table", + "name": "places", + "tbl_name": "places", + "sql": "CREATE TABLE \"places\" (\"id\" TEXT NOT NULL, \"place_name\" TEXT NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_definition_steps", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TABLE \"preparation_definition_steps\" (\"id\" TEXT NOT NULL, \"definition_id\" TEXT NOT NULL REFERENCES preparation_definitions (id), \"name\" TEXT NOT NULL, \"minutes\" INTEGER NOT NULL CHECK (minutes >= 0), \"position\" INTEGER NOT NULL CHECK (position >= 0), PRIMARY KEY (\"id\"), UNIQUE (\"definition_id\", \"position\"))" + }, + { + "type": "table", + "name": "preparation_definitions", + "tbl_name": "preparation_definitions", + "sql": "CREATE TABLE \"preparation_definitions\" (\"id\" TEXT NOT NULL, \"owner_id\" TEXT NOT NULL, \"scope\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_schedules", + "tbl_name": "preparation_schedules", + "sql": "CREATE TABLE \"preparation_schedules\" (\"id\" TEXT NOT NULL, \"schedule_id\" TEXT NOT NULL REFERENCES schedules (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_schedules (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_template_steps", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TABLE \"preparation_template_steps\" (\"id\" TEXT NOT NULL, \"template_id\" TEXT NOT NULL REFERENCES preparation_templates (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"position\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_templates", + "tbl_name": "preparation_templates", + "sql": "CREATE TABLE \"preparation_templates\" (\"id\" TEXT NOT NULL, \"template_name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_users", + "tbl_name": "preparation_users", + "sql": "CREATE TABLE \"preparation_users\" (\"id\" TEXT NOT NULL, \"user_id\" TEXT NOT NULL REFERENCES users (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_users (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "recurring_schedule_exclusions", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TABLE \"recurring_schedule_exclusions\" (\"segment_id\" TEXT NOT NULL REFERENCES recurring_schedule_segments (id), \"slot_key\" TEXT NOT NULL, \"ordinal\" INTEGER NOT NULL, PRIMARY KEY (\"segment_id\", \"slot_key\"))" + }, + { + "type": "table", + "name": "recurring_schedule_segments", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TABLE \"recurring_schedule_segments\" (\"id\" TEXT NOT NULL, \"series_id\" TEXT NOT NULL, \"rule_json\" TEXT NOT NULL, \"schedule_json\" TEXT NOT NULL, \"preparation_id\" TEXT NOT NULL REFERENCES preparation_definitions (id), \"from_slot\" TEXT NOT NULL, \"before_slot\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"preparation_not_before\" INTEGER NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "schedules", + "tbl_name": "schedules", + "sql": "CREATE TABLE \"schedules\" (\"id\" TEXT NOT NULL, \"place_id\" TEXT NOT NULL REFERENCES places (id), \"schedule_name\" TEXT NOT NULL, \"time_zone_id\" TEXT NOT NULL DEFAULT 'UTC', \"occurrence_offset_seconds\" INTEGER NULL, \"schedule_time\" TEXT NOT NULL, \"move_time\" INTEGER NOT NULL, \"is_changed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_changed\" IN (0, 1)), \"is_started\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_started\" IN (0, 1)), \"schedule_spare_time\" INTEGER NULL, \"schedule_note\" TEXT NULL, \"lateness_time\" INTEGER NOT NULL DEFAULT -1, \"done_status\" TEXT NOT NULL DEFAULT 'notEnded', \"started_at\" INTEGER NULL, \"finished_at\" INTEGER NULL, \"preparation_mode\" TEXT NULL, \"preparation_template_id\" TEXT NULL, \"preparation_template_name\" TEXT NULL, \"preparation_template_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_template_deleted\" IN (0, 1)), \"preparation_frozen\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_frozen\" IN (0, 1)), \"score_contribution_recorded\" INTEGER NOT NULL DEFAULT 0 CHECK (\"score_contribution_recorded\" IN (0, 1)), \"recurring_segment_id\" TEXT NULL, \"recurring_slot_key\" TEXT NULL, \"recurring_ordinal\" INTEGER NULL, \"recurring_overrides\" TEXT NOT NULL DEFAULT '', \"preparation_definition_id\" TEXT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "users", + "tbl_name": "users", + "sql": "CREATE TABLE \"users\" (\"id\" TEXT NOT NULL, \"spare_time\" INTEGER NOT NULL, \"note\" TEXT NOT NULL, \"is_onboarding_completed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_onboarding_completed\" IN (0, 1)), \"eligible_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"on_time_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"alarms_enabled\" INTEGER NOT NULL DEFAULT 1 CHECK (\"alarms_enabled\" IN (0, 1)), \"alarm_offset_minutes\" INTEGER NOT NULL DEFAULT 0, \"detailed_notification_content\" INTEGER NOT NULL DEFAULT 0 CHECK (\"detailed_notification_content\" IN (0, 1)), \"data_revision\" INTEGER NOT NULL DEFAULT 0, \"last_exported_revision\" INTEGER NULL, \"last_exported_at\" INTEGER NULL, \"first_durable_data_at\" INTEGER NULL, \"last_durable_data_at\" INTEGER NULL, PRIMARY KEY (\"id\"))" + } + ], + "tables": { + "places": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_definition_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "definition_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_definitions", + "from": "definition_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "u", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "definition_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": "position", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 2, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_definitions": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "owner_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "scope", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_schedules": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "schedule_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_schedules", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "schedules", + "from": "schedule_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_template_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_templates", + "from": "template_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "template_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_templates": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "updated_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_users": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "user_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_users", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "users", + "from": "user_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "user_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "recurring_schedule_exclusions": { + "columns": [ + { + "name": "segment_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "slot_key", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 2, + "hidden": 0 + }, + { + "name": "ordinal", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "recurring_schedule_segments", + "from": "segment_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "segment_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": "slot_key", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 2, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "recurring_schedule_segments": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "series_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "rule_json", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_json", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "from_slot", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "before_slot", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_not_before", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_definitions", + "from": "preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "schedules": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "time_zone_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'UTC'", + "pk": 0, + "hidden": 0 + }, + { + "name": "occurrence_offset_seconds", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_time", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "move_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_changed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "is_started", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_spare_time", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_note", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "lateness_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "-1", + "pk": 0, + "hidden": 0 + }, + { + "name": "done_status", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'notEnded'", + "pk": 0, + "hidden": 0 + }, + { + "name": "started_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "finished_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_mode", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_deleted", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_frozen", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "score_contribution_recorded", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_segment_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_slot_key", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_ordinal", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_overrides", + "type": "TEXT", + "notnull": 1, + "dflt_value": "''", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_definition_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "places", + "from": "place_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_time", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "place_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "users": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "spare_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "note", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_onboarding_completed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "eligible_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "on_time_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarms_enabled", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "1", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarm_offset_minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "detailed_notification_content", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "data_revision", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_revision", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "first_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + } + } + }, + { + "version": 3, + "sourceCommit": "126161dab042365c89983934b1673d860c27d8d3", + "ddlPath": "test/fixtures/database/schema_v3.sql", + "ddlSha256": "4c2d7b8be37fd22ff614bc1844872f23147d8e8623445652e2f949513583b55c", + "engine": "3.51.0", + "objects": [ + { + "type": "index", + "name": "preparation_schedules_next_preparation_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_next_preparation_id_idx ON preparation_schedules (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_schedules_schedule_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_schedule_id_idx ON preparation_schedules (schedule_id)" + }, + { + "type": "index", + "name": "preparation_template_steps_template_id_idx", + "tbl_name": "preparation_template_steps", + "sql": "CREATE INDEX preparation_template_steps_template_id_idx ON preparation_template_steps (template_id)" + }, + { + "type": "index", + "name": "preparation_users_next_preparation_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_next_preparation_id_idx ON preparation_users (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_users_user_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_user_id_idx ON preparation_users (user_id)" + }, + { + "type": "index", + "name": "schedules_place_id_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_place_id_idx ON schedules (place_id)" + }, + { + "type": "index", + "name": "schedules_schedule_time_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_schedule_time_idx ON schedules (schedule_time)" + }, + { + "type": "table", + "name": "places", + "tbl_name": "places", + "sql": "CREATE TABLE \"places\" (\"id\" TEXT NOT NULL, \"place_name\" TEXT NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_definition_steps", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TABLE \"preparation_definition_steps\" (\"id\" TEXT NOT NULL, \"definition_id\" TEXT NOT NULL REFERENCES preparation_definitions (id), \"name\" TEXT NOT NULL, \"minutes\" INTEGER NOT NULL CHECK (minutes >= 0), \"position\" INTEGER NOT NULL CHECK (position >= 0), PRIMARY KEY (\"id\"), UNIQUE (\"definition_id\", \"position\"))" + }, + { + "type": "table", + "name": "preparation_definitions", + "tbl_name": "preparation_definitions", + "sql": "CREATE TABLE \"preparation_definitions\" (\"id\" TEXT NOT NULL, \"owner_id\" TEXT NOT NULL, \"scope\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_schedules", + "tbl_name": "preparation_schedules", + "sql": "CREATE TABLE \"preparation_schedules\" (\"id\" TEXT NOT NULL, \"schedule_id\" TEXT NOT NULL REFERENCES schedules (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_schedules (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_template_steps", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TABLE \"preparation_template_steps\" (\"id\" TEXT NOT NULL, \"template_id\" TEXT NOT NULL REFERENCES preparation_templates (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"position\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_templates", + "tbl_name": "preparation_templates", + "sql": "CREATE TABLE \"preparation_templates\" (\"id\" TEXT NOT NULL, \"template_name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_users", + "tbl_name": "preparation_users", + "sql": "CREATE TABLE \"preparation_users\" (\"id\" TEXT NOT NULL, \"user_id\" TEXT NOT NULL REFERENCES users (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_users (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "recurring_schedule_exclusions", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TABLE \"recurring_schedule_exclusions\" (\"segment_id\" TEXT NOT NULL REFERENCES recurring_schedule_segments (id), \"slot_key\" TEXT NOT NULL, \"ordinal\" INTEGER NOT NULL, PRIMARY KEY (\"segment_id\", \"slot_key\"))" + }, + { + "type": "table", + "name": "recurring_schedule_segments", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TABLE \"recurring_schedule_segments\" (\"root_segment_id\" TEXT NULL, \"aggregate_incarnation\" TEXT NULL, \"aggregate_version\" INTEGER NULL, \"last_mutation_id\" TEXT NULL, \"last_mutation_digest\" TEXT NULL, \"last_mutation_version\" INTEGER NULL, \"id\" TEXT NOT NULL, \"series_id\" TEXT NOT NULL, \"rule_json\" TEXT NOT NULL, \"schedule_json\" TEXT NOT NULL, \"preparation_id\" TEXT NOT NULL REFERENCES preparation_definitions (id), \"from_slot\" TEXT NOT NULL, \"before_slot\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"preparation_not_before\" INTEGER NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "schedules", + "tbl_name": "schedules", + "sql": "CREATE TABLE \"schedules\" (\"aggregate_incarnation\" TEXT NULL, \"aggregate_version\" INTEGER NULL, \"last_mutation_id\" TEXT NULL, \"last_mutation_digest\" TEXT NULL, \"last_mutation_version\" INTEGER NULL, \"id\" TEXT NOT NULL, \"place_id\" TEXT NOT NULL REFERENCES places (id), \"schedule_name\" TEXT NOT NULL, \"time_zone_id\" TEXT NOT NULL DEFAULT 'UTC', \"occurrence_offset_seconds\" INTEGER NULL, \"schedule_time\" TEXT NOT NULL, \"move_time\" INTEGER NOT NULL, \"is_changed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_changed\" IN (0, 1)), \"is_started\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_started\" IN (0, 1)), \"schedule_spare_time\" INTEGER NULL, \"schedule_note\" TEXT NULL, \"lateness_time\" INTEGER NOT NULL DEFAULT -1, \"done_status\" TEXT NOT NULL DEFAULT 'notEnded', \"started_at\" INTEGER NULL, \"finished_at\" INTEGER NULL, \"preparation_mode\" TEXT NULL, \"preparation_template_id\" TEXT NULL, \"preparation_template_name\" TEXT NULL, \"preparation_template_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_template_deleted\" IN (0, 1)), \"preparation_frozen\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_frozen\" IN (0, 1)), \"score_contribution_recorded\" INTEGER NOT NULL DEFAULT 0 CHECK (\"score_contribution_recorded\" IN (0, 1)), \"recurring_segment_id\" TEXT NULL, \"recurring_slot_key\" TEXT NULL, \"recurring_ordinal\" INTEGER NULL, \"recurring_overrides\" TEXT NOT NULL DEFAULT '', \"preparation_definition_id\" TEXT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "users", + "tbl_name": "users", + "sql": "CREATE TABLE \"users\" (\"store_incarnation\" TEXT NULL, \"id\" TEXT NOT NULL, \"spare_time\" INTEGER NOT NULL, \"note\" TEXT NOT NULL, \"is_onboarding_completed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_onboarding_completed\" IN (0, 1)), \"eligible_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"on_time_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"alarms_enabled\" INTEGER NOT NULL DEFAULT 1 CHECK (\"alarms_enabled\" IN (0, 1)), \"alarm_offset_minutes\" INTEGER NOT NULL DEFAULT 0, \"detailed_notification_content\" INTEGER NOT NULL DEFAULT 0 CHECK (\"detailed_notification_content\" IN (0, 1)), \"data_revision\" INTEGER NOT NULL DEFAULT 0, \"last_exported_revision\" INTEGER NULL, \"last_exported_at\" INTEGER NULL, \"first_durable_data_at\" INTEGER NULL, \"last_durable_data_at\" INTEGER NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "trigger", + "name": "definition_steps_delete", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TRIGGER definition_steps_delete AFTER DELETE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=OLD.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=OLD.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=OLD.definition_id); END" + }, + { + "type": "trigger", + "name": "definition_steps_insert", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TRIGGER definition_steps_insert AFTER INSERT ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END" + }, + { + "type": "trigger", + "name": "definition_steps_update", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TRIGGER definition_steps_update AFTER UPDATE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END" + }, + { + "type": "trigger", + "name": "exclusion_delete", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TRIGGER exclusion_delete AFTER DELETE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.segment_id); END" + }, + { + "type": "trigger", + "name": "exclusion_insert", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TRIGGER exclusion_insert AFTER INSERT ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END" + }, + { + "type": "trigger", + "name": "exclusion_update", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TRIGGER exclusion_update AFTER UPDATE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END" + }, + { + "type": "trigger", + "name": "place_version", + "tbl_name": "places", + "sql": "CREATE TRIGGER place_version AFTER UPDATE OF place_name ON places WHEN NEW.place_name IS NOT OLD.place_name BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE place_id = NEW.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.place_id=NEW.id); END" + }, + { + "type": "trigger", + "name": "preparation_default_delete", + "tbl_name": "preparation_users", + "sql": "CREATE TRIGGER preparation_default_delete AFTER DELETE ON preparation_users BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END" + }, + { + "type": "trigger", + "name": "preparation_default_insert", + "tbl_name": "preparation_users", + "sql": "CREATE TRIGGER preparation_default_insert AFTER INSERT ON preparation_users BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END" + }, + { + "type": "trigger", + "name": "preparation_default_update", + "tbl_name": "preparation_users", + "sql": "CREATE TRIGGER preparation_default_update AFTER UPDATE ON preparation_users BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END" + }, + { + "type": "trigger", + "name": "preparation_schedule_delete", + "tbl_name": "preparation_schedules", + "sql": "CREATE TRIGGER preparation_schedule_delete AFTER DELETE ON preparation_schedules BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = OLD.schedule_id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = OLD.schedule_id)); END" + }, + { + "type": "trigger", + "name": "preparation_schedule_insert", + "tbl_name": "preparation_schedules", + "sql": "CREATE TRIGGER preparation_schedule_insert AFTER INSERT ON preparation_schedules BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END" + }, + { + "type": "trigger", + "name": "preparation_schedule_update", + "tbl_name": "preparation_schedules", + "sql": "CREATE TRIGGER preparation_schedule_update AFTER UPDATE ON preparation_schedules BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END" + }, + { + "type": "trigger", + "name": "recurring_schedule_segments_identity", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TRIGGER recurring_schedule_segments_identity AFTER INSERT ON recurring_schedule_segments\n WHEN NEW.aggregate_incarnation IS NULL BEGIN\n UPDATE recurring_schedule_segments SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END" + }, + { + "type": "trigger", + "name": "schedules_deleted", + "tbl_name": "schedules", + "sql": "CREATE TRIGGER schedules_deleted AFTER DELETE ON schedules BEGIN\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END" + }, + { + "type": "trigger", + "name": "schedules_identity", + "tbl_name": "schedules", + "sql": "CREATE TRIGGER schedules_identity AFTER INSERT ON schedules\n WHEN NEW.aggregate_incarnation IS NULL BEGIN\n UPDATE schedules SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END" + }, + { + "type": "trigger", + "name": "schedules_version", + "tbl_name": "schedules", + "sql": "CREATE TRIGGER schedules_version AFTER UPDATE OF place_id,schedule_name,time_zone_id,occurrence_offset_seconds,schedule_time,move_time,is_changed,is_started,schedule_spare_time,schedule_note,lateness_time,done_status,started_at,finished_at,preparation_mode,preparation_template_id,preparation_template_name,preparation_template_deleted,preparation_frozen,score_contribution_recorded,recurring_segment_id,recurring_slot_key,recurring_ordinal,recurring_overrides,preparation_definition_id ON schedules\n WHEN NEW.place_id IS NOT OLD.place_id OR NEW.schedule_name IS NOT OLD.schedule_name OR NEW.time_zone_id IS NOT OLD.time_zone_id OR NEW.occurrence_offset_seconds IS NOT OLD.occurrence_offset_seconds OR NEW.schedule_time IS NOT OLD.schedule_time OR NEW.move_time IS NOT OLD.move_time OR NEW.is_changed IS NOT OLD.is_changed OR NEW.is_started IS NOT OLD.is_started OR NEW.schedule_spare_time IS NOT OLD.schedule_spare_time OR NEW.schedule_note IS NOT OLD.schedule_note OR NEW.lateness_time IS NOT OLD.lateness_time OR NEW.done_status IS NOT OLD.done_status OR NEW.started_at IS NOT OLD.started_at OR NEW.finished_at IS NOT OLD.finished_at OR NEW.preparation_mode IS NOT OLD.preparation_mode OR NEW.preparation_template_id IS NOT OLD.preparation_template_id OR NEW.preparation_template_name IS NOT OLD.preparation_template_name OR NEW.preparation_template_deleted IS NOT OLD.preparation_template_deleted OR NEW.preparation_frozen IS NOT OLD.preparation_frozen OR NEW.score_contribution_recorded IS NOT OLD.score_contribution_recorded OR NEW.recurring_segment_id IS NOT OLD.recurring_segment_id OR NEW.recurring_slot_key IS NOT OLD.recurring_slot_key OR NEW.recurring_ordinal IS NOT OLD.recurring_ordinal OR NEW.recurring_overrides IS NOT OLD.recurring_overrides OR NEW.preparation_definition_id IS NOT OLD.preparation_definition_id BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END" + }, + { + "type": "trigger", + "name": "segment_root", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TRIGGER segment_root AFTER INSERT ON recurring_schedule_segments WHEN NEW.root_segment_id IS NULL BEGIN UPDATE recurring_schedule_segments SET root_segment_id = COALESCE((SELECT root_segment_id FROM recurring_schedule_segments WHERE series_id=NEW.series_id AND root_segment_id IS NOT NULL LIMIT 1), NEW.id) WHERE id=NEW.id; END" + }, + { + "type": "trigger", + "name": "segments_version", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TRIGGER segments_version AFTER UPDATE OF rule_json,schedule_json,preparation_id,from_slot,before_slot,preparation_not_before ON recurring_schedule_segments\n WHEN NEW.rule_json IS NOT OLD.rule_json OR NEW.schedule_json IS NOT OLD.schedule_json OR NEW.preparation_id IS NOT OLD.preparation_id OR NEW.from_slot IS NOT OLD.from_slot OR NEW.before_slot IS NOT OLD.before_slot OR NEW.preparation_not_before IS NOT OLD.preparation_not_before BEGIN\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.id); END" + }, + { + "type": "trigger", + "name": "template_delete", + "tbl_name": "preparation_templates", + "sql": "CREATE TRIGGER template_delete AFTER DELETE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END" + }, + { + "type": "trigger", + "name": "template_steps_delete", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TRIGGER template_steps_delete AFTER DELETE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.template_id); END" + }, + { + "type": "trigger", + "name": "template_steps_insert", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TRIGGER template_steps_insert AFTER INSERT ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END" + }, + { + "type": "trigger", + "name": "template_steps_update", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TRIGGER template_steps_update AFTER UPDATE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END" + }, + { + "type": "trigger", + "name": "template_update", + "tbl_name": "preparation_templates", + "sql": "CREATE TRIGGER template_update AFTER UPDATE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END" + }, + { + "type": "trigger", + "name": "users_identity", + "tbl_name": "users", + "sql": "CREATE TRIGGER users_identity AFTER INSERT ON users WHEN NEW.store_incarnation IS NULL BEGIN\n UPDATE users SET store_incarnation = lower(hex(randomblob(16))) WHERE id = NEW.id; END" + } + ], + "tables": { + "places": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_definition_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "definition_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_definitions", + "from": "definition_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "u", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "definition_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": "position", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 2, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_definitions": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "owner_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "scope", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_schedules": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "schedule_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_schedules", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "schedules", + "from": "schedule_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_template_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_templates", + "from": "template_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "template_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_templates": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "updated_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_users": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "user_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_users", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "users", + "from": "user_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "user_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "recurring_schedule_exclusions": { + "columns": [ + { + "name": "segment_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "slot_key", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 2, + "hidden": 0 + }, + { + "name": "ordinal", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "recurring_schedule_segments", + "from": "segment_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "segment_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": "slot_key", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 2, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "recurring_schedule_segments": { + "columns": [ + { + "name": "root_segment_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_incarnation", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_digest", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "series_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "rule_json", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_json", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "from_slot", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "before_slot", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_not_before", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_definitions", + "from": "preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "schedules": { + "columns": [ + { + "name": "aggregate_incarnation", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_digest", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "time_zone_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'UTC'", + "pk": 0, + "hidden": 0 + }, + { + "name": "occurrence_offset_seconds", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_time", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "move_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_changed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "is_started", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_spare_time", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_note", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "lateness_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "-1", + "pk": 0, + "hidden": 0 + }, + { + "name": "done_status", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'notEnded'", + "pk": 0, + "hidden": 0 + }, + { + "name": "started_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "finished_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_mode", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_deleted", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_frozen", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "score_contribution_recorded", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_segment_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_slot_key", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_ordinal", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_overrides", + "type": "TEXT", + "notnull": 1, + "dflt_value": "''", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_definition_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "places", + "from": "place_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_time", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "place_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "users": { + "columns": [ + { + "name": "store_incarnation", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "spare_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "note", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_onboarding_completed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "eligible_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "on_time_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarms_enabled", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "1", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarm_offset_minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "detailed_notification_content", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "data_revision", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_revision", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "first_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + } + } + }, + { + "version": 4, + "sourceCommit": "e0a668f8c84edc54f4c7501399409e6a79d6510b", + "ddlPath": "test/fixtures/database/schema_v4.sql", + "ddlSha256": "60a5c9fef7385280e55adc622cf9f33f507de180a4c35d4c3d09285fcfd9d0ea", + "engine": "3.51.0", + "objects": [ + { + "type": "index", + "name": "preparation_schedules_next_preparation_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_next_preparation_id_idx ON preparation_schedules (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_schedules_schedule_id_idx", + "tbl_name": "preparation_schedules", + "sql": "CREATE INDEX preparation_schedules_schedule_id_idx ON preparation_schedules (schedule_id)" + }, + { + "type": "index", + "name": "preparation_template_steps_template_id_idx", + "tbl_name": "preparation_template_steps", + "sql": "CREATE INDEX preparation_template_steps_template_id_idx ON preparation_template_steps (template_id)" + }, + { + "type": "index", + "name": "preparation_users_next_preparation_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_next_preparation_id_idx ON preparation_users (next_preparation_id)" + }, + { + "type": "index", + "name": "preparation_users_user_id_idx", + "tbl_name": "preparation_users", + "sql": "CREATE INDEX preparation_users_user_id_idx ON preparation_users (user_id)" + }, + { + "type": "index", + "name": "schedules_place_id_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_place_id_idx ON schedules (place_id)" + }, + { + "type": "index", + "name": "schedules_schedule_time_idx", + "tbl_name": "schedules", + "sql": "CREATE INDEX schedules_schedule_time_idx ON schedules (schedule_time)" + }, + { + "type": "table", + "name": "places", + "tbl_name": "places", + "sql": "CREATE TABLE \"places\" (\"id\" TEXT NOT NULL, \"place_name\" TEXT NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_definition_steps", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TABLE \"preparation_definition_steps\" (\"id\" TEXT NOT NULL, \"definition_id\" TEXT NOT NULL REFERENCES preparation_definitions (id), \"name\" TEXT NOT NULL, \"minutes\" INTEGER NOT NULL CHECK (minutes >= 0), \"position\" INTEGER NOT NULL CHECK (position >= 0), PRIMARY KEY (\"id\"), UNIQUE (\"definition_id\", \"position\"))" + }, + { + "type": "table", + "name": "preparation_definitions", + "tbl_name": "preparation_definitions", + "sql": "CREATE TABLE \"preparation_definitions\" (\"id\" TEXT NOT NULL, \"owner_id\" TEXT NOT NULL, \"scope\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_schedules", + "tbl_name": "preparation_schedules", + "sql": "CREATE TABLE \"preparation_schedules\" (\"id\" TEXT NOT NULL, \"schedule_id\" TEXT NOT NULL REFERENCES schedules (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_schedules (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_template_steps", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TABLE \"preparation_template_steps\" (\"id\" TEXT NOT NULL, \"template_id\" TEXT NOT NULL REFERENCES preparation_templates (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"position\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_templates", + "tbl_name": "preparation_templates", + "sql": "CREATE TABLE \"preparation_templates\" (\"id\" TEXT NOT NULL, \"template_name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "preparation_users", + "tbl_name": "preparation_users", + "sql": "CREATE TABLE \"preparation_users\" (\"id\" TEXT NOT NULL, \"user_id\" TEXT NOT NULL REFERENCES users (id), \"preparation_name\" TEXT NOT NULL, \"preparation_time\" INTEGER NOT NULL, \"next_preparation_id\" TEXT NULL REFERENCES preparation_users (id), PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "recurring_schedule_exclusions", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TABLE \"recurring_schedule_exclusions\" (\"segment_id\" TEXT NOT NULL REFERENCES recurring_schedule_segments (id), \"slot_key\" TEXT NOT NULL, \"ordinal\" INTEGER NOT NULL, PRIMARY KEY (\"segment_id\", \"slot_key\"))" + }, + { + "type": "table", + "name": "recurring_schedule_segments", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TABLE \"recurring_schedule_segments\" (\"root_segment_id\" TEXT NULL, \"aggregate_incarnation\" TEXT NULL, \"aggregate_version\" INTEGER NULL, \"last_mutation_id\" TEXT NULL, \"last_mutation_digest\" TEXT NULL, \"last_mutation_version\" INTEGER NULL, \"id\" TEXT NOT NULL, \"series_id\" TEXT NOT NULL, \"rule_json\" TEXT NOT NULL, \"schedule_json\" TEXT NOT NULL, \"preparation_id\" TEXT NOT NULL REFERENCES preparation_definitions (id), \"from_slot\" TEXT NOT NULL, \"before_slot\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"preparation_not_before\" INTEGER NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "schedules", + "tbl_name": "schedules", + "sql": "CREATE TABLE \"schedules\" (\"requires_start_confirmation\" INTEGER NOT NULL DEFAULT 0 CHECK (\"requires_start_confirmation\" IN (0, 1)), \"aggregate_incarnation\" TEXT NULL, \"aggregate_version\" INTEGER NULL, \"last_mutation_id\" TEXT NULL, \"last_mutation_digest\" TEXT NULL, \"last_mutation_version\" INTEGER NULL, \"id\" TEXT NOT NULL, \"place_id\" TEXT NOT NULL REFERENCES places (id), \"schedule_name\" TEXT NOT NULL, \"time_zone_id\" TEXT NOT NULL DEFAULT 'UTC', \"occurrence_offset_seconds\" INTEGER NULL, \"schedule_time\" TEXT NOT NULL, \"move_time\" INTEGER NOT NULL, \"is_changed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_changed\" IN (0, 1)), \"is_started\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_started\" IN (0, 1)), \"schedule_spare_time\" INTEGER NULL, \"schedule_note\" TEXT NULL, \"lateness_time\" INTEGER NOT NULL DEFAULT -1, \"done_status\" TEXT NOT NULL DEFAULT 'notEnded', \"started_at\" INTEGER NULL, \"finished_at\" INTEGER NULL, \"preparation_mode\" TEXT NULL, \"preparation_template_id\" TEXT NULL, \"preparation_template_name\" TEXT NULL, \"preparation_template_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_template_deleted\" IN (0, 1)), \"preparation_frozen\" INTEGER NOT NULL DEFAULT 0 CHECK (\"preparation_frozen\" IN (0, 1)), \"score_contribution_recorded\" INTEGER NOT NULL DEFAULT 0 CHECK (\"score_contribution_recorded\" IN (0, 1)), \"recurring_segment_id\" TEXT NULL, \"recurring_slot_key\" TEXT NULL, \"recurring_ordinal\" INTEGER NULL, \"recurring_overrides\" TEXT NOT NULL DEFAULT '', \"preparation_definition_id\" TEXT NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "table", + "name": "users", + "tbl_name": "users", + "sql": "CREATE TABLE \"users\" (\"restore_cleanup_pending\" INTEGER NOT NULL DEFAULT 0 CHECK (\"restore_cleanup_pending\" IN (0, 1)), \"reject_legacy_delivery\" INTEGER NOT NULL DEFAULT 0 CHECK (\"reject_legacy_delivery\" IN (0, 1)), \"store_incarnation\" TEXT NULL, \"id\" TEXT NOT NULL, \"spare_time\" INTEGER NOT NULL, \"note\" TEXT NOT NULL, \"is_onboarding_completed\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_onboarding_completed\" IN (0, 1)), \"eligible_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"on_time_outcome_count\" INTEGER NOT NULL DEFAULT 0, \"alarms_enabled\" INTEGER NOT NULL DEFAULT 1 CHECK (\"alarms_enabled\" IN (0, 1)), \"alarm_offset_minutes\" INTEGER NOT NULL DEFAULT 0, \"detailed_notification_content\" INTEGER NOT NULL DEFAULT 0 CHECK (\"detailed_notification_content\" IN (0, 1)), \"data_revision\" INTEGER NOT NULL DEFAULT 0, \"last_exported_revision\" INTEGER NULL, \"last_exported_at\" INTEGER NULL, \"first_durable_data_at\" INTEGER NULL, \"last_durable_data_at\" INTEGER NULL, PRIMARY KEY (\"id\"))" + }, + { + "type": "trigger", + "name": "definition_steps_delete", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TRIGGER definition_steps_delete AFTER DELETE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=OLD.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=OLD.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=OLD.definition_id); END" + }, + { + "type": "trigger", + "name": "definition_steps_insert", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TRIGGER definition_steps_insert AFTER INSERT ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END" + }, + { + "type": "trigger", + "name": "definition_steps_update", + "tbl_name": "preparation_definition_steps", + "sql": "CREATE TRIGGER definition_steps_update AFTER UPDATE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END" + }, + { + "type": "trigger", + "name": "exclusion_delete", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TRIGGER exclusion_delete AFTER DELETE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.segment_id); END" + }, + { + "type": "trigger", + "name": "exclusion_insert", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TRIGGER exclusion_insert AFTER INSERT ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END" + }, + { + "type": "trigger", + "name": "exclusion_update", + "tbl_name": "recurring_schedule_exclusions", + "sql": "CREATE TRIGGER exclusion_update AFTER UPDATE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END" + }, + { + "type": "trigger", + "name": "place_version", + "tbl_name": "places", + "sql": "CREATE TRIGGER place_version AFTER UPDATE OF place_name ON places WHEN NEW.place_name IS NOT OLD.place_name BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE place_id = NEW.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.place_id=NEW.id); END" + }, + { + "type": "trigger", + "name": "preparation_default_delete", + "tbl_name": "preparation_users", + "sql": "CREATE TRIGGER preparation_default_delete AFTER DELETE ON preparation_users BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END" + }, + { + "type": "trigger", + "name": "preparation_default_insert", + "tbl_name": "preparation_users", + "sql": "CREATE TRIGGER preparation_default_insert AFTER INSERT ON preparation_users BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END" + }, + { + "type": "trigger", + "name": "preparation_default_update", + "tbl_name": "preparation_users", + "sql": "CREATE TRIGGER preparation_default_update AFTER UPDATE ON preparation_users BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END" + }, + { + "type": "trigger", + "name": "preparation_schedule_delete", + "tbl_name": "preparation_schedules", + "sql": "CREATE TRIGGER preparation_schedule_delete AFTER DELETE ON preparation_schedules BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = OLD.schedule_id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = OLD.schedule_id)); END" + }, + { + "type": "trigger", + "name": "preparation_schedule_insert", + "tbl_name": "preparation_schedules", + "sql": "CREATE TRIGGER preparation_schedule_insert AFTER INSERT ON preparation_schedules BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END" + }, + { + "type": "trigger", + "name": "preparation_schedule_update", + "tbl_name": "preparation_schedules", + "sql": "CREATE TRIGGER preparation_schedule_update AFTER UPDATE ON preparation_schedules BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END" + }, + { + "type": "trigger", + "name": "recurring_schedule_segments_identity", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TRIGGER recurring_schedule_segments_identity AFTER INSERT ON recurring_schedule_segments\n WHEN NEW.aggregate_incarnation IS NULL BEGIN\n UPDATE recurring_schedule_segments SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END" + }, + { + "type": "trigger", + "name": "schedules_deleted", + "tbl_name": "schedules", + "sql": "CREATE TRIGGER schedules_deleted AFTER DELETE ON schedules BEGIN\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END" + }, + { + "type": "trigger", + "name": "schedules_identity", + "tbl_name": "schedules", + "sql": "CREATE TRIGGER schedules_identity AFTER INSERT ON schedules\n WHEN NEW.aggregate_incarnation IS NULL BEGIN\n UPDATE schedules SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END" + }, + { + "type": "trigger", + "name": "schedules_version", + "tbl_name": "schedules", + "sql": "CREATE TRIGGER schedules_version AFTER UPDATE OF place_id,schedule_name,time_zone_id,occurrence_offset_seconds,schedule_time,move_time,is_changed,is_started,schedule_spare_time,schedule_note,lateness_time,done_status,started_at,finished_at,preparation_mode,preparation_template_id,preparation_template_name,preparation_template_deleted,preparation_frozen,score_contribution_recorded,recurring_segment_id,recurring_slot_key,recurring_ordinal,recurring_overrides,preparation_definition_id ON schedules\n WHEN NEW.place_id IS NOT OLD.place_id OR NEW.schedule_name IS NOT OLD.schedule_name OR NEW.time_zone_id IS NOT OLD.time_zone_id OR NEW.occurrence_offset_seconds IS NOT OLD.occurrence_offset_seconds OR NEW.schedule_time IS NOT OLD.schedule_time OR NEW.move_time IS NOT OLD.move_time OR NEW.is_changed IS NOT OLD.is_changed OR NEW.is_started IS NOT OLD.is_started OR NEW.schedule_spare_time IS NOT OLD.schedule_spare_time OR NEW.schedule_note IS NOT OLD.schedule_note OR NEW.lateness_time IS NOT OLD.lateness_time OR NEW.done_status IS NOT OLD.done_status OR NEW.started_at IS NOT OLD.started_at OR NEW.finished_at IS NOT OLD.finished_at OR NEW.preparation_mode IS NOT OLD.preparation_mode OR NEW.preparation_template_id IS NOT OLD.preparation_template_id OR NEW.preparation_template_name IS NOT OLD.preparation_template_name OR NEW.preparation_template_deleted IS NOT OLD.preparation_template_deleted OR NEW.preparation_frozen IS NOT OLD.preparation_frozen OR NEW.score_contribution_recorded IS NOT OLD.score_contribution_recorded OR NEW.recurring_segment_id IS NOT OLD.recurring_segment_id OR NEW.recurring_slot_key IS NOT OLD.recurring_slot_key OR NEW.recurring_ordinal IS NOT OLD.recurring_ordinal OR NEW.recurring_overrides IS NOT OLD.recurring_overrides OR NEW.preparation_definition_id IS NOT OLD.preparation_definition_id BEGIN\n UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.id;\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END" + }, + { + "type": "trigger", + "name": "segment_root", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TRIGGER segment_root AFTER INSERT ON recurring_schedule_segments WHEN NEW.root_segment_id IS NULL BEGIN UPDATE recurring_schedule_segments SET root_segment_id = COALESCE((SELECT root_segment_id FROM recurring_schedule_segments WHERE series_id=NEW.series_id AND root_segment_id IS NOT NULL LIMIT 1), NEW.id) WHERE id=NEW.id; END" + }, + { + "type": "trigger", + "name": "segments_version", + "tbl_name": "recurring_schedule_segments", + "sql": "CREATE TRIGGER segments_version AFTER UPDATE OF rule_json,schedule_json,preparation_id,from_slot,before_slot,preparation_not_before ON recurring_schedule_segments\n WHEN NEW.rule_json IS NOT OLD.rule_json OR NEW.schedule_json IS NOT OLD.schedule_json OR NEW.preparation_id IS NOT OLD.preparation_id OR NEW.from_slot IS NOT OLD.from_slot OR NEW.before_slot IS NOT OLD.before_slot OR NEW.preparation_not_before IS NOT OLD.preparation_not_before BEGIN\n UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.id); END" + }, + { + "type": "trigger", + "name": "template_delete", + "tbl_name": "preparation_templates", + "sql": "CREATE TRIGGER template_delete AFTER DELETE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END" + }, + { + "type": "trigger", + "name": "template_steps_delete", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TRIGGER template_steps_delete AFTER DELETE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.template_id); END" + }, + { + "type": "trigger", + "name": "template_steps_insert", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TRIGGER template_steps_insert AFTER INSERT ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END" + }, + { + "type": "trigger", + "name": "template_steps_update", + "tbl_name": "preparation_template_steps", + "sql": "CREATE TRIGGER template_steps_update AFTER UPDATE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END" + }, + { + "type": "trigger", + "name": "template_update", + "tbl_name": "preparation_templates", + "sql": "CREATE TRIGGER template_update AFTER UPDATE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END" + }, + { + "type": "trigger", + "name": "users_identity", + "tbl_name": "users", + "sql": "CREATE TRIGGER users_identity AFTER INSERT ON users WHEN NEW.store_incarnation IS NULL BEGIN\n UPDATE users SET store_incarnation = lower(hex(randomblob(16))) WHERE id = NEW.id; END" + } + ], + "tables": { + "places": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_definition_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "definition_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_definitions", + "from": "definition_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "u", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "definition_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": "position", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 2, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_definitions": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "owner_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "scope", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_schedules": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "schedule_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_schedules", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "schedules", + "from": "schedule_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_template_steps": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "position", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_templates", + "from": "template_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "template_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_templates": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "template_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "updated_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "preparation_users": { + "columns": [ + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "user_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "next_preparation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_users", + "from": "next_preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + }, + { + "id": 1, + "seq": 0, + "table": "users", + "from": "user_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "user_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "next_preparation_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "recurring_schedule_exclusions": { + "columns": [ + { + "name": "segment_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "slot_key", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 2, + "hidden": 0 + }, + { + "name": "ordinal", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "recurring_schedule_segments", + "from": "segment_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "segment_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": "slot_key", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 2, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "recurring_schedule_segments": { + "columns": [ + { + "name": "root_segment_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_incarnation", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_digest", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "series_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "rule_json", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_json", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "from_slot", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "before_slot", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "created_at", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_not_before", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "preparation_definitions", + "from": "preparation_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "schedules": { + "columns": [ + { + "name": "requires_start_confirmation", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_incarnation", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "aggregate_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_digest", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_mutation_version", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "place_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_name", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "time_zone_id", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'UTC'", + "pk": 0, + "hidden": 0 + }, + { + "name": "occurrence_offset_seconds", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_time", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "move_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_changed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "is_started", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_spare_time", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "schedule_note", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "lateness_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "-1", + "pk": 0, + "hidden": 0 + }, + { + "name": "done_status", + "type": "TEXT", + "notnull": 1, + "dflt_value": "'notEnded'", + "pk": 0, + "hidden": 0 + }, + { + "name": "started_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "finished_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_mode", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_template_deleted", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_frozen", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "score_contribution_recorded", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_segment_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_slot_key", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_ordinal", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "recurring_overrides", + "type": "TEXT", + "notnull": 1, + "dflt_value": "''", + "pk": 0, + "hidden": 0 + }, + { + "name": "preparation_definition_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [ + { + "id": 0, + "seq": 0, + "table": "places", + "from": "place_id", + "to": "id", + "on_update": "NO ACTION", + "on_delete": "NO ACTION", + "match": "NONE" + } + ], + "indexes": [ + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "schedule_time", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 0, + "origin": "c", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "place_id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + }, + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + }, + "users": { + "columns": [ + { + "name": "restore_cleanup_pending", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "reject_legacy_delivery", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "store_incarnation", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "id", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 1, + "hidden": 0 + }, + { + "name": "spare_time", + "type": "INTEGER", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "note", + "type": "TEXT", + "notnull": 1, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "is_onboarding_completed", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "eligible_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "on_time_outcome_count", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarms_enabled", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "1", + "pk": 0, + "hidden": 0 + }, + { + "name": "alarm_offset_minutes", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "detailed_notification_content", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "data_revision", + "type": "INTEGER", + "notnull": 1, + "dflt_value": "0", + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_revision", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_exported_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "first_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + }, + { + "name": "last_durable_data_at", + "type": "INTEGER", + "notnull": 0, + "dflt_value": null, + "pk": 0, + "hidden": 0 + } + ], + "foreignKeys": [], + "indexes": [ + { + "unique": 1, + "origin": "pk", + "partial": 0, + "columns": [ + { + "seqno": 0, + "name": "id", + "desc": 0, + "coll": "BINARY", + "key": 1 + }, + { + "seqno": 1, + "name": null, + "desc": 0, + "coll": "BINARY", + "key": 0 + } + ] + } + ] + } + } + } + ] +} diff --git a/lib/core/database/sqlcipher_loader.dart b/lib/core/database/sqlcipher_loader.dart new file mode 100644 index 00000000..af8d274f --- /dev/null +++ b/lib/core/database/sqlcipher_loader.dart @@ -0,0 +1,11 @@ +import 'dart:io'; +import 'package:sqlite3/open.dart'; +import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart'; + +/// Called before the first SQLite open in each isolate. Android's sqlite3 +/// default loads libsqlite3, whereas our bundled library is libsqlcipher. +void configureSqlCipherLoader() { + if (Platform.isAndroid) { + open.overrideFor(OperatingSystem.android, openCipherOnAndroid); + } +} diff --git a/lib/core/services/alarm_operation_coordinator.dart b/lib/core/services/alarm_operation_coordinator.dart index c3c7577d..07116767 100644 --- a/lib/core/services/alarm_operation_coordinator.dart +++ b/lib/core/services/alarm_operation_coordinator.dart @@ -12,7 +12,7 @@ import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart final class AlarmOperationCoordinator extends ChangeNotifier { AlarmOperationCoordinator(this.gate, {AlarmOwnershipJournal? journal}) : journal = journal ?? AlarmOwnershipJournal(MemoryAlarmJournalStore()) { - gate.addListener(notifyListeners); + gate.addListener(_gateChanged); } static final shared = AlarmOperationCoordinator( @@ -24,8 +24,70 @@ final class AlarmOperationCoordinator extends ChangeNotifier { Future _tail = Future.value(); final _unpersisted = Expando>(); + int _contentGeneration = -1; + int _contentRevision = 0; + bool _unresolvedPrivateIntent = false; + bool? _confirmedDetailed; + + void _syncContentGeneration() { + if (_contentGeneration == generation) return; + _contentGeneration = generation; + _contentRevision = 0; + _unresolvedPrivateIntent = false; + _confirmedDetailed = null; + } + + void _gateChanged() { + _syncContentGeneration(); + notifyListeners(); + } + + /// Synchronous acceptance fences every reconciliation entry point. This is + /// ephemeral content authority, not a new installation generation or journal. + AlarmContentPermit acceptContentIntent( + bool enabled, { + required int expectedGeneration, + }) { + gate.checkWrite(expectedGeneration); + _syncContentGeneration(); + _contentRevision++; + _unresolvedPrivateIntent = !enabled; + final permit = captureContentPermit(); + notifyListeners(); + return permit; + } + + void confirmContentIntent( + AlarmContentPermit permit, { + required bool committedEnabled, + }) { + if (!isCurrentContentPermit(permit)) return; + _confirmedDetailed = committedEnabled; + // A failed OFF write followed by an ON read-back must retain the hold. + if (!committedEnabled) _unresolvedPrivateIntent = false; + } + + /// Capture BEFORE reading the settings/content snapshot, never afterwards. + AlarmContentPermit captureContentPermit() { + _syncContentGeneration(); + return AlarmContentPermit._(generation, _contentRevision); + } + + bool isCurrentContentPermit(AlarmContentPermit permit) { + _syncContentGeneration(); + return permit.generation == generation && + permit.revision == _contentRevision && + canSchedule; + } + + bool allowsDetailed(AlarmContentPermit permit) => + isCurrentContentPermit(permit) && + !_unresolvedPrivateIntent && + _confirmedDetailed != false; + int get generation => gate.generation; - bool get canSchedule => !gate.isReplacingData && !gate.isInvalidated; + bool get canSchedule => + !gate.isReplacingData && !gate.isInvalidated && !gate.isRecoveryPending; AlarmOperationLease capture() { final lease = AlarmOperationLease._(this, generation); @@ -138,11 +200,18 @@ final class AlarmOperationCoordinator extends ChangeNotifier { @override void dispose() { - gate.removeListener(notifyListeners); + gate.removeListener(_gateChanged); super.dispose(); } } +/// A pass cannot regain authority after a newer OFF/ON intent completes. +final class AlarmContentPermit { + const AlarmContentPermit._(this.generation, this.revision); + final int generation; + final int revision; +} + final class AlarmOperationLease { AlarmOperationLease._(this._owner, this.generation); final AlarmOperationCoordinator _owner; diff --git a/lib/core/services/alarm_registration_cleanup.dart b/lib/core/services/alarm_registration_cleanup.dart index 3bbb8d31..29c311ac 100644 --- a/lib/core/services/alarm_registration_cleanup.dart +++ b/lib/core/services/alarm_registration_cleanup.dart @@ -176,18 +176,35 @@ final class AlarmRegistrationCleanup { } Future> cancelRecords( - List records, - ) async { + List records, { + bool Function()? isCurrent, + Future Function()? isStillOwned, + }) async { + Future check() async { + if (!(isCurrent?.call() ?? true) || + !(await isStillOwned?.call() ?? true) || + !(isCurrent?.call() ?? true)) { + throw const AlarmOperationInvalidated(); + } + } + + await check(); if (records.isNotEmpty) await operations.remember(registry, records); + await check(); final failed = []; for (final record in records) { + await check(); try { if (record.provider == AlarmProvider.localNotification) { await fallback.cancelFallbackAlarm(record); } else if (record.provider != AlarmProvider.none) { await scheduler.cancelNativeAlarm(record); } + await check(); await operations.confirmedCancelled(registry, record); + await check(); + } on AlarmOperationInvalidated { + rethrow; } catch (_) { failed.add(AlarmOperationCoordinator.ownershipOnly(record)); } @@ -200,19 +217,39 @@ final class AlarmRegistrationCleanup { if (!report.isComplete) throw AlarmCleanupIncomplete(report: report); } - Future cancelMatchingReport({String? scheduleId}) async { + Future cancelMatchingReport({ + String? scheduleId, + bool Function()? isCurrent, + Future Function()? isStillOwned, + }) async { + Future check() async { + if (!(isCurrent?.call() ?? true) || + !(await isStillOwned?.call() ?? true) || + !(isCurrent?.call() ?? true)) { + throw const AlarmOperationInvalidated(); + } + } + + await check(); final stored = await operations.loadRecords(registry); + await check(); final targets = stored .where( (record) => scheduleId == null || record.scheduleId == scheduleId, ) .toList(); - final failed = await cancelRecords(targets); + final failed = await cancelRecords( + targets, + isCurrent: isCurrent, + isStillOwned: isStillOwned, + ); + await check(); await operations.replaceRecords(registry, [ if (scheduleId != null) ...stored.where((record) => record.scheduleId != scheduleId), ...failed, ]); + await check(); return AlarmCleanupReport( targets.length, failed.length, diff --git a/lib/core/services/detailed_notification_preference_service.dart b/lib/core/services/detailed_notification_preference_service.dart index d3dc46b3..3dd66211 100644 --- a/lib/core/services/detailed_notification_preference_service.dart +++ b/lib/core/services/detailed_notification_preference_service.dart @@ -1,22 +1,81 @@ import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/constants/local_profile.dart'; import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; + +final class DetailedNotificationPreferenceSnapshot { + const DetailedNotificationPreferenceSnapshot({ + required this.generation, + required this.detailedEnabled, + required this.scheduleNotificationsEnabled, + }); + final int generation; + final bool detailedEnabled; + final bool scheduleNotificationsEnabled; +} + +final class DetailedNotificationProfileUnavailable implements Exception { + const DetailedNotificationProfileUnavailable(); +} + +abstract interface class DetailedNotificationPreferencePort { + Future read({ + required int expectedGeneration, + }); + Future write( + bool enabled, { + required int expectedGeneration, + }); +} @lazySingleton -class DetailedNotificationPreferenceService { - DetailedNotificationPreferenceService(this._database); +class DetailedNotificationPreferenceService + implements DetailedNotificationPreferencePort { + DetailedNotificationPreferenceService( + this._database, { + @ignoreParam LocalDataOperationGate? gate, + }) : _gate = gate ?? LocalDataOperationGate.shared; final AppDatabase _database; + final LocalDataOperationGate _gate; - Future getEnabled() async { - final settings = await _database.userDao.getAlarmSettings(localProfileId); - return settings.detailedNotificationContent; + @override + Future read({ + required int expectedGeneration, + }) async { + _gate.checkWrite(expectedGeneration); + final row = await (_database.select( + _database.users, + )..where((user) => user.id.equals(localProfileId))).getSingleOrNull(); + _gate.checkWrite(expectedGeneration); + if (row == null) throw const DetailedNotificationProfileUnavailable(); + return DetailedNotificationPreferenceSnapshot( + generation: expectedGeneration, + detailedEnabled: row.detailedNotificationContent, + scheduleNotificationsEnabled: row.alarmsEnabled, + ); } - Future setEnabled(bool enabled) { - return _database.userDao.updateDetailedNotificationContent( - userId: localProfileId, - enabled: enabled, - ); + @override + Future write( + bool enabled, { + required int expectedGeneration, + }) { + _gate.checkWrite(expectedGeneration); + return _database.writeTransaction(() async { + _gate.checkWrite(expectedGeneration); + await _database.userDao.updateDetailedNotificationContent( + userId: localProfileId, + enabled: enabled, + ); + return read(expectedGeneration: expectedGeneration); + }, gate: _gate); + } + + // Retained for callers outside the settings controller; both are strict. + Future getEnabled() async => + (await read(expectedGeneration: _gate.generation)).detailedEnabled; + Future setEnabled(bool enabled) async { + await write(enabled, expectedGeneration: _gate.generation); } } diff --git a/lib/core/services/local_time_zone_service.dart b/lib/core/services/local_time_zone_service.dart index 3e362016..68f2279f 100644 --- a/lib/core/services/local_time_zone_service.dart +++ b/lib/core/services/local_time_zone_service.dart @@ -1,3 +1,4 @@ +import '../time/time_zone_rules.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -5,16 +6,21 @@ abstract final class LocalTimeZoneService { static const _channel = MethodChannel('on_time_front/native_alarm'); static Future current() async { - if (kIsWeb) return 'UTC'; + if (kIsWeb) throw const LocalTimeZoneUnavailable(); try { final identifier = await _channel.invokeMethod( 'getLocalTimeZone', ); - return identifier == null || identifier.isEmpty ? 'UTC' : identifier; + if (identifier == null || !TimeZoneRules.contains(identifier)) { + throw const LocalTimeZoneUnavailable(); + } + return identifier; } catch (_) { - // A missing platform binding/plugin must not prevent local scheduling or - // recovery mode from starting. UTC is the deterministic safe fallback. - return 'UTC'; + throw const LocalTimeZoneUnavailable(); } } } + +class LocalTimeZoneUnavailable implements Exception { + const LocalTimeZoneUnavailable(); +} diff --git a/lib/core/services/notification_content.dart b/lib/core/services/notification_content.dart index c50d411f..3b8c16ba 100644 --- a/lib/core/services/notification_content.dart +++ b/lib/core/services/notification_content.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:convert'; import 'package:on_time_front/core/services/notification_routing.dart'; @@ -28,6 +29,9 @@ Map preparationStepNotificationPayload({ }) { return { 'type': 'preparation_step', + if (RestoreRuntimeIdentity.shared.storeIncarnation + case final String identity) + 'storeIncarnation': identity, 'scheduleId': scheduleId, 'stepId': stepId, }; diff --git a/lib/core/services/notification_routing.dart b/lib/core/services/notification_routing.dart index d90a5dea..8be543d4 100644 --- a/lib/core/services/notification_routing.dart +++ b/lib/core/services/notification_routing.dart @@ -86,7 +86,12 @@ Map? safeNotificationTapData(String? raw) { RegExp(r'[\x00-\x1f\x7f]').hasMatch(id)) { return null; } - return {'type': 'preparation_step', 'scheduleId': id}; + return { + 'type': 'preparation_step', + 'scheduleId': id, + if (value['storeIncarnation'] is String) + 'storeIncarnation': value['storeIncarnation'], + }; } } on FormatException { return null; diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 230a00a2..aa89b2e6 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -18,7 +18,7 @@ import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/entities/delivery_observation.dart'; import 'package:permission_handler/permission_handler.dart' as permission_handler; -import 'package:timezone/data/latest.dart' as tz_data; +import '../time/time_zone_rules.dart'; import 'package:timezone/timezone.dart' as tz; enum AuthorizationStatus { authorized, denied, notDetermined, provisional } @@ -584,7 +584,7 @@ class NotificationService { Future _ensureTimezoneInitialized() async { if (_isTimezoneInitialized) return; - tz_data.initializeTimeZones(); + TimeZoneRules.ensureInitialized(); if (_isIOS) { try { final identifier = await _nativeAlarmChannel.invokeMethod( diff --git a/lib/core/services/notification_tap_router.dart b/lib/core/services/notification_tap_router.dart index 22e3ed3e..5828a9b7 100644 --- a/lib/core/services/notification_tap_router.dart +++ b/lib/core/services/notification_tap_router.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'package:on_time_front/domain/entities/notification_route_payload.dart'; import 'dart:async'; @@ -81,7 +82,10 @@ class NavigationNotificationTapRouter implements NotificationTapRouter { @override void routeLocalNotificationTap(String? payload) { final data = safeNotificationTapData(payload); - if (data == null) return; + if (data == null || + !RestoreRuntimeIdentity.shared.accepts(data['storeIncarnation'])) { + return; + } final target = notificationRouteForData(data); if (target == null) return; _receive(target); @@ -89,7 +93,10 @@ class NavigationNotificationTapRouter implements NotificationTapRouter { void routeNativeNotificationTap(Map payload) { final safe = minimalScheduleRoutePayload(payload); - if (safe.isEmpty) return; + if (safe.isEmpty || + !RestoreRuntimeIdentity.shared.accepts(safe['storeIncarnation'])) { + return; + } final target = notificationRouteForData(safe); if (target != null) _receive(target); } @@ -152,6 +159,10 @@ class NavigationNotificationTapRouter implements NotificationTapRouter { _pending = null; return; } + if (!RestoreRuntimeIdentity.shared.accepts(payload['storeIncarnation'])) { + _pending = null; + return; + } final result = await resolve(id, () => _current(tap)); if (!_current(tap)) return; if (!_dataGate.isAvailable || !(_isReady?.call() ?? false)) return; diff --git a/lib/core/services/runtime_privacy_migration.dart b/lib/core/services/runtime_privacy_migration.dart index ad44b5db..8c604a4f 100644 --- a/lib/core/services/runtime_privacy_migration.dart +++ b/lib/core/services/runtime_privacy_migration.dart @@ -1,4 +1,5 @@ import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:flutter/services.dart'; import 'package:on_time_front/core/database/bootstrap_privacy_boundary.dart'; import 'package:injectable/injectable.dart'; @@ -25,8 +26,11 @@ class RuntimePrivacyMigration { this.registry, { @ignoreParam Future Function()? cleanPlatform, @ignoreParam AlarmOperationCoordinator? operations, + @ignoreParam DateTime Function()? now, }) : _cleanPlatform = cleanPlatform, + _now = now ?? DateTime.now, _operations = operations ?? AlarmOperationCoordinator.shared; + final DateTime Function() _now; final AlarmOperationCoordinator _operations; final Future Function()? _cleanPlatform; final ScheduleRepository schedules; @@ -43,6 +47,7 @@ class RuntimePrivacyMigration { } Future _run() async { + final evaluationNow = _now().toUtc(); final prefs = await SharedPreferences.getInstance(); for (final key in prefs.getKeys().where((key) => key.startsWith(_prefix)).toList()) { @@ -50,12 +55,17 @@ class RuntimePrivacyMigration { TimedPreparationSnapshotEntity? replacement; try { final stored = await snapshots.loadPreparation(id); + final rawSchedule = await schedules.getScheduleById(id); final schedule = ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( - await schedules.getScheduleById(id), + rawSchedule, PreparationWithTimeEntity.fromPreparation( await preparations.getPreparationByScheduleId(id), ), + timeResolution: ScheduleTimeResolver.resolve( + rawSchedule, + nowUtc: evaluationNow, + ), ); if (schedule.doneStatus == ScheduleDoneStatus.notEnded) { replacement = stored == null @@ -63,11 +73,16 @@ class RuntimePrivacyMigration { : validatePreparationSnapshot(stored, schedule); replacement ??= TimedPreparationSnapshotEntity( preparation: schedule.preparation, - savedAt: DateTime.now(), + savedAt: evaluationNow, scheduleFingerprint: schedule.cacheFingerprint, requiresConfirmation: true, ); } + } on ScheduleTimeUnresolved { + // This row cannot authorize reconstructing a run. Remove only its + // installed transient projection below; the encrypted facts remain. + // Storage/read/cleanup failures are deliberately not caught here. + replacement = null; } catch (error, stack) { // Drift's getSingle signals a missing schedule as StateError('No element'). // Other errors can mean the encrypted DB became unavailable; do not diff --git a/lib/core/startup/startup_dependency_scope.dart b/lib/core/startup/startup_dependency_scope.dart new file mode 100644 index 00000000..97cfcf7d --- /dev/null +++ b/lib/core/startup/startup_dependency_scope.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'package:get_it/get_it.dart'; + +/// One attempted product graph, including resources whose constructor fails +/// before GetIt can register them. Not an owner of shared native operations. +/// Product constructors use the single GetIt.I container. The Zone also supports +/// explicit registration/creation within this attempt; arbitrary other containers +/// must use track instead of relying on the product's late-resolution fallback. +final class StartupDependencyScope { + StartupDependencyScope(this.container); + final GetIt container; + static final _zoneKey = Object(); + static final _owners = Expando(); + static int _next = 0; + final String name = 'startup-${_next++}'; + final List<_Cleanup> _resources = []; + bool _opened = false; + bool _popped = false; + Future? _cleanupFlight; + + static void own(Object resource, FutureOr Function() dispose) { + final scoped = GetIt.I.isRegistered() + ? GetIt.I() + : null; + final owner = + Zone.current[_zoneKey] as StartupDependencyScope? ?? + (scoped != null && scoped.container.currentScopeName == scoped.name + ? scoped + : null); + if (owner == null) return; + _owners[resource] = owner; + owner._resources.add(_Cleanup(resource, dispose)); + } + + /// GetIt must continue LIFO cleanup even when one resource fails. The owner + /// retains that resource and its real Future, independently of registration. + static Future release( + Object resource, + FutureOr Function() dispose, + ) async { + final owner = _owners[resource]; + if (owner == null) { + await dispose(); + return; + } + await owner._resources + .firstWhere((entry) => identical(entry.resource, resource)) + .run(); + } + + T track(T Function() action) => + runZoned(action, zoneValues: {_zoneKey: this}); + + static Future releaseChecked( + Object resource, + FutureOr Function() dispose, + ) async { + await release(resource, dispose); + final owner = _owners[resource]; + if (owner != null && + !owner._resources + .firstWhere((entry) => identical(entry.resource, resource)) + .done) { + throw const StartupCleanupIncomplete(); + } + } + + T configure(T Function() register) { + if (_opened) throw StateError('Startup scope already opened'); + // No init callback: GetIt 9.2.1's synchronous init failure cannot await + // disposer Futures. Registration and owned rollback are separate phases. + container.pushNewScope(scopeName: name); + _opened = true; + container.registerSingleton(this); + return track(register); + } + + bool get cleanupComplete => + (!_opened || _popped) && _resources.every((entry) => entry.done); + + Future cleanup() => + _cleanupFlight ??= _cleanup().whenComplete(() => _cleanupFlight = null); + + Future _cleanup() async { + final attemptedBefore = _resources + .where((entry) => entry.attempted) + .toSet(); + if (_opened && !_popped) { + if (container.currentScopeName != name) { + throw const StartupCleanupIncomplete(); + } + try { + await container.popScope(); + } catch (_) { + // Scope absence and resource termination remain different facts. + _popped = !container.hasScope(name); + throw const StartupCleanupIncomplete(); + } + _popped = !container.hasScope(name); + } + for (final entry in _resources.reversed) { + // First pass handles unregistered constructor resources, but never + // immediately repeats a failed disposer already called by popScope. + if (!entry.attempted || attemptedBefore.contains(entry)) { + await entry.run(); + } + } + if (!cleanupComplete) throw const StartupCleanupIncomplete(); + for (final entry in _resources) { + _owners[entry.resource] = null; + } + _resources.clear(); + } +} + +final class StartupCleanupIncomplete implements Exception { + const StartupCleanupIncomplete(); + @override + String toString() => 'Startup resource cleanup remains incomplete'; +} + +final class _Cleanup { + _Cleanup(this.resource, this.dispose); + final Object resource; + final FutureOr Function() dispose; + bool done = false; + bool attempted = false; + Future? _flight; + Future run() { + if (done) return Future.value(); + return _flight ??= _run().whenComplete(() => _flight = null); + } + + Future _run() async { + attempted = true; + try { + await dispose(); + done = true; + } catch (_) { + // Keep the bounded owner reference. A later explicit cleanup retry may + // retry this terminal failure; an unreturned Future is never duplicated. + } + } +} diff --git a/lib/core/startup/startup_failure.dart b/lib/core/startup/startup_failure.dart new file mode 100644 index 00000000..38000a82 --- /dev/null +++ b/lib/core/startup/startup_failure.dart @@ -0,0 +1,40 @@ +/// Safe startup facts. Causes stay internal and are never display strings. +enum StartupStage { + locale, + lifecycle, + temporaryFiles, + store, + dependencies, + cleanup, +} + +final class StartupFailure implements Exception { + const StartupFailure(this.stage, this.cause, {this.allowsReset = false}); + final StartupStage stage; + final Object cause; + final bool allowsReset; + @override + String toString() => 'Startup is incomplete (${stage.name})'; +} + +/// Existing installation evidence cannot be treated as a fresh installation. +final class LocalStorePreservationRequired implements Exception { + const LocalStorePreservationRequired(); + @override + String toString() => 'Local store requires recovery'; +} + +Future startupStage( + StartupStage stage, + Future Function() action, +) async { + try { + return await action(); + } catch (cause) { + throw StartupFailure( + stage, + cause, + allowsReset: cause is LocalStorePreservationRequired, + ); + } +} diff --git a/lib/core/startup/subscription_cleanup.dart b/lib/core/startup/subscription_cleanup.dart new file mode 100644 index 00000000..4c7f924d --- /dev/null +++ b/lib/core/startup/subscription_cleanup.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +/// Retired watches remain owned until their real cancel Future finishes. +final class SubscriptionCleanup { + final _entries = , _Cancellation>{}; + void retire(StreamSubscription? subscription) { + if (subscription == null) return; + final entry = _entries.putIfAbsent( + subscription, + () => _Cancellation(subscription), + ); + unawaited( + entry.run().then((_) { + if (entry.done && identical(_entries[subscription], entry)) { + _entries.remove(subscription); + } + }), + ); + } + + Future cancelAndWait(StreamSubscription? subscription) async { + if (subscription == null) return; + final entry = _entries.putIfAbsent( + subscription, + () => _Cancellation(subscription), + ); + await entry.run(); + if (!entry.done) throw StateError('Watch cleanup is incomplete'); + if (identical(_entries[subscription], entry)) _entries.remove(subscription); + } + + Future close() async { + final entries = _entries.values.toList(); + await Future.wait(entries.map((entry) => entry.run())); + _entries.removeWhere((_, entry) => entry.done); + if (_entries.isNotEmpty) throw StateError('Watch cleanup is incomplete'); + } +} + +final class _Cancellation { + _Cancellation(this.subscription); + final StreamSubscription subscription; + bool done = false; + Future? _flight; + Future run() => done + ? Future.value() + : _flight ??= _run().whenComplete(() => _flight = null); + Future _run() async { + try { + await subscription.cancel(); + done = true; + } catch (_) { + /* Owner retains failed cancellation for explicit cleanup. */ + } + } +} diff --git a/lib/core/time/civil_time_resolver.dart b/lib/core/time/civil_time_resolver.dart index 8c02d3bf..8ddf8dca 100644 --- a/lib/core/time/civil_time_resolver.dart +++ b/lib/core/time/civil_time_resolver.dart @@ -1,23 +1,10 @@ -import 'package:equatable/equatable.dart'; -import 'package:timezone/data/latest.dart' as tz_data; +import '../../domain/entities/civil_time_occurrence.dart'; +export '../../domain/entities/civil_time_occurrence.dart'; +import 'time_zone_rules.dart'; +import '../../domain/entities/civil_date_time.dart'; import 'package:timezone/timezone.dart' as tz; -class CivilTimeOccurrence extends Equatable { - const CivilTimeOccurrence({ - required this.offsetSeconds, - required this.instantUtc, - }); - - final int offsetSeconds; - final DateTime instantUtc; - - @override - List get props => [offsetSeconds, instantUtc]; -} - abstract final class CivilTimeResolver { - static bool _initialized = false; - /// Resolves a wall-clock selection in [timeZoneId] to every valid instant. /// /// A normal civil time has one occurrence, a spring-forward gap has none, @@ -28,17 +15,8 @@ abstract final class CivilTimeResolver { String timeZoneId, ) { _ensureInitialized(); - final location = _locationOrUtc(timeZoneId); - final civilAsUtc = DateTime.utc( - civilTime.year, - civilTime.month, - civilTime.day, - civilTime.hour, - civilTime.minute, - civilTime.second, - civilTime.millisecond, - civilTime.microsecond, - ); + final location = _location(timeZoneId); + final civil = CivilDateTime.fromFields(civilTime); // Enumerate actual zone offsets and round-trip each candidate. This also // preserves historical second-level offsets without 73 lookups per slot. @@ -48,7 +26,12 @@ abstract final class CivilTimeResolver { final occurrences = []; for (final offsetSeconds in candidateOffsets) { - final instant = civilAsUtc.subtract(Duration(seconds: offsetSeconds)); + DateTime instant; + try { + instant = civil.atOffset(offsetSeconds); + } on FormatException { + continue; // Outside the persisted year range is not a valid occurrence. + } final roundTrip = tz.TZDateTime.from(instant, location); if (_hasSameCivilFields(civilTime, roundTrip)) { occurrences.add( @@ -65,32 +48,49 @@ abstract final class CivilTimeResolver { return occurrences; } + /// A proposal only. The caller must obtain an explicit user selection before + /// modifying a schedule; no gap is silently normalized or persisted. + static DateTime? nextValidCivilTime(DateTime civilTime, String timeZoneId) { + if (!TimeZoneRules.contains(timeZoneId)) return null; + final carrier = CivilDateTime.fromFields(civilTime).toUtcCarrier(); + final minute = DateTime.utc( + carrier.year, + carrier.month, + carrier.day, + carrier.hour, + carrier.minute, + ); + for (var distance = 1; distance <= 2880; distance++) { + final candidate = minute.add(Duration(minutes: distance)); + if (candidate.year > 9999) return null; + if (resolve(candidate, timeZoneId).isNotEmpty) return candidate; + } + return null; + } + static DateTime civilTimeAt(DateTime instant, String timeZoneId) { _ensureInitialized(); - return tz.TZDateTime.from(instant.toUtc(), _locationOrUtc(timeZoneId)); + return tz.TZDateTime.from(instant.toUtc(), _location(timeZoneId)); } static String formatUtcOffset(int offsetSeconds) { + if (offsetSeconds < -86400 || offsetSeconds > 86400) { + throw const FormatException('Occurrence offset is out of range'); + } final sign = offsetSeconds < 0 ? '-' : '+'; final totalMinutes = offsetSeconds.abs() ~/ 60; final hours = (totalMinutes ~/ 60).toString().padLeft(2, '0'); final minutes = (totalMinutes % 60).toString().padLeft(2, '0'); - return 'UTC$sign$hours:$minutes'; + final seconds = offsetSeconds.abs() % 60; + return 'UTC$sign$hours:$minutes${seconds == 0 ? '' : ':${seconds.toString().padLeft(2, '0')}'}'; } static void _ensureInitialized() { - if (_initialized) return; - tz_data.initializeTimeZones(); - _initialized = true; + TimeZoneRules.ensureInitialized(); } - static tz.Location _locationOrUtc(String timeZoneId) { - try { - return tz.getLocation(timeZoneId); - } catch (_) { - return tz.UTC; - } - } + static tz.Location _location(String timeZoneId) => + TimeZoneRules.location(timeZoneId); static bool _hasSameCivilFields(DateTime source, DateTime candidate) { return source.year == candidate.year && diff --git a/lib/core/time/device_civil_day.dart b/lib/core/time/device_civil_day.dart new file mode 100644 index 00000000..160e3c87 --- /dev/null +++ b/lib/core/time/device_civil_day.dart @@ -0,0 +1,14 @@ +/// The device's calendar day on the instant axis. DST days are not always 24h. +final class DeviceCivilDay { + DeviceCivilDay.at(DateTime instant) { + final local = instant.toLocal(); + startUtc = DateTime(local.year, local.month, local.day).toUtc(); + endUtc = DateTime(local.year, local.month, local.day + 1).toUtc(); + } + + late final DateTime startUtc; + late final DateTime endUtc; + + bool contains(DateTime instant) => + !instant.isBefore(startUtc) && instant.isBefore(endUtc); +} diff --git a/lib/core/time/recurring_time_correction_mapping.dart b/lib/core/time/recurring_time_correction_mapping.dart new file mode 100644 index 00000000..c5e523a7 --- /dev/null +++ b/lib/core/time/recurring_time_correction_mapping.dart @@ -0,0 +1,398 @@ +import 'package:on_time_front/domain/entities/backup_recurring_time_review.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +export 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_recurrence_scan.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_reference_policy.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'schedule_time_resolution.dart'; +import 'time_zone_rules.dart'; + +/// Bounded, read-only mapping shared by active review and authenticated staging. +/// It never chooses IDs, writes records, invokes a repository or grants commit +/// authority. The caller must revalidate its source/rule/clock claim at commit. +abstract final class RecurringTimeCorrectionMapper { + static Future build({ + required ScheduleEntity anchor, + required List segments, + required List schedules, + required List exclusions, + required Map occurrenceDefinitionOwners, + required RecurrenceRule requested, + required bool endExplicitlyChosen, + required DateTime nowUtc, + required BackupBudget budget, + }) { + if (anchor.recurringSegmentId == null || + anchor.recurringSlotKey == null || + anchor.recurringOrdinal == null) { + BackupLimits.invalid(); + } + return _build( + segmentId: anchor.recurringSegmentId!, + anchorCivil: CivilDateTime.parse(anchor.recurringSlotKey!).toUtcCarrier(), + anchorOrdinal: anchor.recurringOrdinal, + anchorId: anchor.id, + segments: segments, + schedules: schedules, + exclusions: exclusions, + occurrenceDefinitionOwners: occurrenceDefinitionOwners, + requested: requested, + endExplicitlyChosen: endExplicitlyChosen, + nowUtc: nowUtc, + budget: budget, + ); + } + + static Future buildCandidate({ + required BackupRecurringTimeAnchor anchor, + required DateTime closeAt, + required bool wholeSource, + required List segments, + required List schedules, + required List exclusions, + required Map occurrenceDefinitionOwners, + required RecurrenceRule requested, + required bool endExplicitlyChosen, + required DateTime nowUtc, + required BackupBudget budget, + }) => _build( + segmentId: anchor.segmentId, + anchorCivil: closeAt, + anchorOrdinal: anchor.originalOrdinal, + anchorId: anchor.scheduleId, + includeAllFuture: wholeSource, + segments: segments, + schedules: schedules, + exclusions: exclusions, + occurrenceDefinitionOwners: occurrenceDefinitionOwners, + requested: requested, + endExplicitlyChosen: endExplicitlyChosen, + nowUtc: nowUtc, + budget: budget, + ); + + static Future _build({ + required String segmentId, + required DateTime anchorCivil, + required int? anchorOrdinal, + required String? anchorId, + bool includeAllFuture = false, + required List segments, + required List schedules, + required List exclusions, + required Map occurrenceDefinitionOwners, + required RecurrenceRule requested, + required bool endExplicitlyChosen, + required DateTime nowUtc, + required BackupBudget budget, + }) async { + final at = nowUtc.toUtc(); + final segmentById = {for (final segment in segments) segment.id: segment}; + final source = segmentById[segmentId]; + if (source == null || + segmentById.length != segments.length || + segments.any((value) => value.seriesId != source.seriesId)) { + BackupLimits.invalid(); + } + if (requested.start.isBefore(RecurrenceRule.civilDate(anchorCivil))) { + throw const RecurrenceValidationException( + 'The new rule precedes its anchor.', + ); + } + final allRefs = >{ + for (final segment in segments) segment.id: [], + }; + final scheduleIds = {}; + final futureRows = []; + final protectedRows = []; + for (final row in schedules) { + budget.visit(); + if (!scheduleIds.add(row.id) || + row.recurringSegmentId == null || + row.recurringSlotKey == null || + row.recurringOrdinal == null || + !segmentById.containsKey(row.recurringSegmentId)) { + BackupLimits.invalid(); + } + final slot = CivilDateTime.parse(row.recurringSlotKey!).toUtcCarrier(); + final resolved = ScheduleTimeResolver.resolve(row, nowUtc: at); + final protected = + RecurrenceReferencePolicy.hasProtectedFacts(row) || + row.startedAt != null || + row.finishedAt != null || + row.scoreContributionRecorded || + resolved.isHistorical || + RecurrenceReferencePolicy.isProvablyPast(row, at); + allRefs[row.recurringSegmentId!]!.add( + _Reference(slot, row.recurringOrdinal!, protected), + ); + if (!slot.isBefore(anchorCivil) || (includeAllFuture && !protected)) { + (protected ? protectedRows : futureRows).add(row); + } + } + final futureExclusions = []; + for (final exclusion in exclusions) { + budget.visit(); + final refs = allRefs[exclusion.segmentId]; + if (refs == null) BackupLimits.invalid(); + final slot = CivilDateTime.parse(exclusion.slot).toUtcCarrier(); + refs.add(_Reference(slot, exclusion.ordinal, true)); + if (!slot.isBefore(anchorCivil)) futureExclusions.add(exclusion); + } + if (anchorId != null && !scheduleIds.contains(anchorId)) { + BackupLimits.invalid(); + } + if (anchorId == null && + (!endExplicitlyChosen || + (requested.count == null && requested.until == null))) { + throw const RecurringTimeCorrectionCountRequired(); + } + for (final segment in segments) { + await _validateReferences(segment, allRefs[segment.id]!, budget); + } + + var rule = requested; + var autoCount = false; + if (!endExplicitlyChosen && source.rule.count != null) { + final open = segments + .where( + (value) => + value.beforeSlot == null || + value.beforeSlot!.isAfter(anchorCivil), + ) + .toList(); + if (open.length != 1 || + open.single.id != source.id || + source.beforeSlot != null || + !TimeZoneRules.contains(source.rule.timeZoneId)) { + throw const RecurringTimeCorrectionCountRequired(); + } + BackupRuleCandidate? observed; + await for (final candidate in scanBackupRule( + rule: source.rule, + through: anchorCivil, + budget: budget, + leadTime: source.leadTime, + preparationNotBefore: source.preparationNotBefore, + )) { + if (candidate.civil == anchorCivil) observed = candidate; + } + final ordinal = anchorOrdinal; + if (ordinal == null || + observed == null || + !observed.eligible || + observed.currentOrdinal != ordinal || + ordinal < 1 || + ordinal > source.rule.count!) { + throw const RecurringTimeCorrectionCountRequired(); + } + rule = requested.withStartAndEnd(count: source.rule.count! - ordinal + 1); + autoCount = true; + } else if (!endExplicitlyChosen && + segments.where((value) => value.beforeSlot == null).length != 1) { + throw const RecurringTimeCorrectionCountRequired(); + } + if (!TimeZoneRules.contains(rule.timeZoneId)) { + throw const RecurrenceValidationException('Choose a known time zone.'); + } + + final compatible = []; + final detached = []; + for (final row in futureRows) { + budget.visit(); + final definition = row.preparationDefinitionId; + final own = + definition != null && + occurrenceDefinitionOwners[definition] == row.id; + if (definition == source.preparationId || + (own && row.recurringOverrides.split(',').contains('preparation'))) { + compatible.add(row); + } else { + // A time correction cannot re-own or silently clone a preparation. + detached.add(row); + } + } + final overrides = compatible + .where((row) => row.recurringOverrides.isNotEmpty) + .toList(); + final ordinary = + compatible.where((row) => row.recurringOverrides.isEmpty).toList() + ..sort((a, b) { + final slot = CivilDateTime.parse( + a.recurringSlotKey!, + ).compareTo(CivilDateTime.parse(b.recurringSlotKey!)); + if (slot != 0) return slot; + final ordinal = a.recurringOrdinal!.compareTo(b.recurringOrdinal!); + return ordinal != 0 ? ordinal : a.id.compareTo(b.id); + }); + final reservations = {}; + void reserve(String slot, Object value) { + final day = _day(CivilDateTime.parse(slot).toUtcCarrier()); + // Two different retained intents cannot silently collapse into one slot. + if (reservations.containsKey(day)) { + throw const RecurrenceValidationException( + 'Several preserved references share a new slot. Choose another anchor.', + ); + } + reservations[day] = value; + } + + for (final row in [...protectedRows, ...overrides]) { + reserve(row.recurringSlotKey!, row); + } + for (final exclusion in futureExclusions) { + reserve(exclusion.slot, exclusion); + } + var throughNeeded = rule.start; + for (final row in [...futureRows, ...protectedRows]) { + final slot = CivilDateTime.parse(row.recurringSlotKey!).toUtcCarrier(); + if (slot.isAfter(throughNeeded)) throughNeeded = slot; + } + for (final exclusion in futureExclusions) { + final slot = CivilDateTime.parse(exclusion.slot).toUtcCarrier(); + if (slot.isAfter(throughNeeded)) throughNeeded = slot; + } + final byDay = {}; + final free = []; + RecurrenceSlot? firstSlot; + await for (final candidate in scanBackupRule( + rule: rule, + through: DateTime.utc(9999, 12, 31, 23, 59, 59, 999, 999), + budget: budget, + leadTime: Duration.zero, + )) { + if (candidate.offsets.length > 1 && rule.repeatedTime == null) { + throw RepeatedTimeChoiceRequired(candidate.civil); + } + if (candidate.eligible) { + final offset = rule.repeatedTime == RepeatedCivilTime.second + ? candidate.offsets.last + : candidate.offsets.first; + final slot = RecurrenceSlot( + civilTime: candidate.civil, + instantUtc: CivilDateTime.fromFields( + candidate.civil, + ).atOffset(offset), + offsetSeconds: offset, + ordinal: candidate.currentOrdinal, + ); + final day = _day(candidate.civil); + firstSlot ??= slot; + if (reservations.containsKey(day)) { + byDay[day] = slot; + } else if (free.length < ordinary.length) { + free.add(slot); + } + } + if (rule.count != null && candidate.currentOrdinal >= rule.count!) break; + if (firstSlot != null && + !candidate.civil.isBefore(throughNeeded) && + free.length >= ordinary.length) { + break; + } + } + final rows = []; + for (final row in overrides) { + rows.add( + TimeCorrectionRowMapping( + row, + byDay[_day( + CivilDateTime.parse(row.recurringSlotKey!).toUtcCarrier(), + )], + ), + ); + } + for (var i = 0; i < ordinary.length; i++) { + rows.add( + TimeCorrectionRowMapping(ordinary[i], i < free.length ? free[i] : null), + ); + } + rows.addAll(detached.map((row) => TimeCorrectionRowMapping(row, null))); + return RecurringTimeCorrectionMapping( + rule: rule, + automaticallyRetainedCount: autoCount, + rows: rows, + protectedRows: protectedRows, + exclusions: [ + for (final exclusion in futureExclusions) + TimeCorrectionExclusionMapping( + exclusion, + byDay[_day(CivilDateTime.parse(exclusion.slot).toUtcCarrier())], + ), + ], + protectedSlots: [ + for (final row in protectedRows) + ?byDay[_day( + CivilDateTime.parse(row.recurringSlotKey!).toUtcCarrier(), + )], + ], + workUnits: budget.work, + firstSlot: firstSlot, + ); + } + + static String _day(DateTime value) => + RecurrenceRule.civilDate(value).toIso8601String(); + + static Future _validateReferences( + RecurringSegment segment, + List<_Reference> references, + BackupBudget budget, + ) async { + if (references.isEmpty) return; + references.sort((a, b) => a.civil.compareTo(b.civil)); + var ordinal = 0; + DateTime? previous; + for (final reference in references) { + budget.visit(); + if (reference.ordinal <= ordinal || + previous == reference.civil || + reference.civil.isBefore(segment.fromSlot) || + (segment.rule.count != null && + reference.ordinal > segment.rule.count!) || + (segment.beforeSlot != null && + !reference.civil.isBefore(segment.beforeSlot!) && + !reference.retained)) { + BackupLimits.invalid(); + } + previous = reference.civil; + ordinal = reference.ordinal; + } + var index = 0; + // Calendar membership is independent of unknown/changed zone rules. This + // scan is not used to assign or prove historical occurrence instants. + await for (final candidate in scanBackupRule( + rule: segment.rule, + through: references.last.civil, + budget: budget, + leadTime: Duration.zero, + offsetLookup: (_, _, meter) { + meter.visit(); + return const [0]; + }, + )) { + if (index == references.length) break; + final reference = references[index]; + if (candidate.civil.isAfter(reference.civil)) BackupLimits.invalid(); + if (candidate.civil == reference.civil) { + if (reference.ordinal > candidate.calendarOrdinal) { + BackupLimits.invalid(); + } + index++; + } + } + if (index != references.length) BackupLimits.invalid(); + } +} + +final class _Reference { + const _Reference(this.civil, this.ordinal, this.retained); + final DateTime civil; + final int ordinal; + final bool retained; +} diff --git a/lib/core/time/schedule_time_resolution.dart b/lib/core/time/schedule_time_resolution.dart new file mode 100644 index 00000000..7e9c2db3 --- /dev/null +++ b/lib/core/time/schedule_time_resolution.dart @@ -0,0 +1,103 @@ +import '../../domain/entities/schedule_time_resolution.dart'; +export '../../domain/entities/schedule_time_resolution.dart'; +import '../../domain/entities/civil_date_time.dart'; +import '../../domain/entities/schedule_entity.dart'; +import '../../domain/recurrence/recurrence_reference_policy.dart'; +import 'civil_time_resolver.dart'; +import 'time_zone_rules.dart'; + +abstract final class ScheduleTimeResolver { + static ScheduleTimeResolution resolve( + ScheduleEntity schedule, { + required DateTime nowUtc, + List Function(DateTime civilCarrier, String zone)? + lookup, + }) { + try { + final civil = CivilDateTime.fromFields(schedule.scheduleTime); + final offset = schedule.occurrenceOffsetSeconds; + final stored = offset == null ? null : civil.atOffset(offset); + final now = nowUtc.toUtc(); + final history = + RecurrenceReferencePolicy.hasProtectedFacts(schedule) || + (stored?.isBefore(now) ?? + civil + .toUtcCarrier() + .add(const Duration(hours: 24)) + .isBefore(now)); + if (!TimeZoneRules.contains(schedule.timeZoneId)) { + return ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.unknownZone, + isHistorical: history, + ); + } + if (history) { + return ScheduleTimeResolution( + status: stored == null + ? ScheduleTimeResolutionStatus.historicalUncertain + : ScheduleTimeResolutionStatus.resolved, + instantUtc: stored, + isHistorical: true, + ); + } + final occurrences = List.of( + (lookup ?? CivilTimeResolver.resolve)( + civil.toUtcCarrier(), + schedule.timeZoneId, + ), + )..sort((a, b) => a.instantUtc.compareTo(b.instantUtc)); + final offsets = {}; + for (final candidate in occurrences) { + if (!candidate.instantUtc.isUtc || + !civil + .atOffset(candidate.offsetSeconds) + .isAtSameMomentAs(candidate.instantUtc) || + !offsets.add(candidate.offsetSeconds)) { + throw const FormatException('Invalid occurrence candidate'); + } + } + if (offset == null && + occurrences.isNotEmpty && + occurrences.every((value) => value.instantUtc.isBefore(now))) { + return ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.historicalUncertain, + occurrences: occurrences, + isHistorical: true, + ); + } + if (occurrences.isEmpty) { + return ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.nonexistent, + ); + } + if (stored != null) { + final matches = occurrences.where( + (candidate) => candidate.offsetSeconds == offset, + ); + return ScheduleTimeResolution( + status: matches.isEmpty + ? ScheduleTimeResolutionStatus.changed + : ScheduleTimeResolutionStatus.resolved, + instantUtc: matches.isEmpty ? null : stored, + proposedInstantUtc: matches.isEmpty && occurrences.length == 1 + ? occurrences.single.instantUtc + : null, + occurrences: occurrences, + ); + } + return ScheduleTimeResolution( + status: occurrences.length == 1 + ? ScheduleTimeResolutionStatus.resolved + : ScheduleTimeResolutionStatus.ambiguous, + instantUtc: occurrences.length == 1 + ? occurrences.single.instantUtc + : null, + occurrences: occurrences, + ); + } on Exception { + return ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.invalid, + ); + } + } +} diff --git a/lib/core/time/time_correction_conflicts.dart b/lib/core/time/time_correction_conflicts.dart new file mode 100644 index 00000000..b0bf890c --- /dev/null +++ b/lib/core/time/time_correction_conflicts.dart @@ -0,0 +1,353 @@ +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +export 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_recurrence_scan.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'schedule_time_resolution.dart'; +import 'time_zone_rules.dart'; + +/// Detached records, materialized overrides and original protected facts are +/// supplied as stored intervals. Other rules are expanded only for the proof +/// range; none of this operation materializes or writes an occurrence. +abstract final class TimeCorrectionConflictScanner { + static int _period(RecurrenceRule rule) => switch (rule.frequency) { + RecurrenceFrequency.daily => rule.interval, + RecurrenceFrequency.weekly => 7 * rule.interval, + RecurrenceFrequency.monthly => + 146097 * (rule.interval ~/ rule.interval.gcd(4800)), + }; + + static DateTime _proofEnd( + RecurrenceRule rule, + List segments, + Iterable exceptions, + BackupBudget budget, + ) { + if (rule.until != null) { + final end = rule.until!; + return DateTime.utc(end.year, end.month, end.day, 23, 59, 59, 999, 999); + } + if (rule.count != null) { + return DateTime.utc(9999, 12, 31, 23, 59, 59, 999, 999); + } + var days = 1; + var anchor = rule.start; + for (final value in [rule, ...segments.map((e) => e.rule)]) { + budget.visit(); + final period = _period(value); + days = days ~/ days.gcd(period) * period; + if (days > 146097 * 10) BackupLimits.exceeded('recurrenceProofPeriod'); + if (value.start.isAfter(anchor)) anchor = value.start; + final transitions = TimeZoneRules.location(value.timeZoneId).transitionAt; + if (transitions.isNotEmpty) { + final last = DateTime.fromMillisecondsSinceEpoch( + transitions.last, + isUtc: true, + ).add(const Duration(days: 2)); + if (last.isAfter(anchor)) anchor = last; + } + } + for (final value in exceptions) { + budget.visit(); + final after = value.add(const Duration(days: 2)); + if (after.isAfter(anchor)) anchor = after; + } + final end = anchor.add(Duration(days: days + 7)); + if (end.year > 9999) BackupLimits.exceeded('recurrenceProofRange'); + return end; + } + + static String canonicalSlot(String value) => + CivilDateTime.parse(value).toUtcCarrier().toIso8601String(); + + static Stream _slots( + RecurrenceRule rule, + DateTime through, + BackupBudget budget, { + Duration lead = Duration.zero, + DateTime? cutoff, + bool requireFullCount = true, + bool strictSelection = true, + void Function(DateTime civil, List offsets)? onUnselected, + }) async* { + var ordinal = 0; + await for (final candidate in scanBackupRule( + rule: rule, + through: through, + budget: budget, + leadTime: lead, + preparationNotBefore: cutoff, + )) { + if (candidate.offsets.length > 1 && rule.repeatedTime == null) { + if (strictSelection) throw RepeatedTimeChoiceRequired(candidate.civil); + onUnselected?.call(candidate.civil, candidate.offsets); + continue; + } + if (candidate.eligible) { + ordinal = candidate.currentOrdinal; + final offset = rule.repeatedTime == RepeatedCivilTime.second + ? candidate.offsets.last + : candidate.offsets.first; + yield RecurrenceSlot( + civilTime: candidate.civil, + instantUtc: CivilDateTime.fromFields( + candidate.civil, + ).atOffset(offset), + offsetSeconds: offset, + ordinal: ordinal, + ); + } + if (rule.count != null && candidate.currentOrdinal >= rule.count!) break; + } + if (requireFullCount && + rule.count != null && + ordinal < rule.count! && + through.year == 9999) { + throw const RecurrenceValidationException( + 'The complete occurrence count could not be verified.', + ); + } + } + + static bool _overlaps( + TimeCorrectionBusyInterval a, + TimeCorrectionBusyInterval b, + ) => + a.instantUtc == b.instantUtc || + (a.preparationStartUtc.isBefore(b.instantUtc) && + b.preparationStartUtc.isBefore(a.instantUtc)); + + static Future scan({ + required RecurrenceRule rule, + required ScheduleEntity base, + required Duration basePreparation, + required Map proposedOverrides, + required Map proposedPreparationById, + required Set excludedSlots, + required List storedOthers, + required List otherSegments, + required Map> otherExcludedSlots, + required DateTime nowUtc, + required BackupBudget budget, + }) async { + final excluded = excludedSlots.map(canonicalSlot).toSet(); + final through = _proofEnd(rule, otherSegments, [ + for (final row in storedOthers) row.schedule.scheduleTime, + for (final row in proposedOverrides.values) row.scheduleTime, + for (final keys in otherExcludedSlots.values) + for (final key in keys) CivilDateTime.parse(key).toUtcCarrier(), + ], budget); + final proposed = <(RecurrenceSlot, TimeCorrectionBusyInterval)>[]; + final mapped = { + for (final entry in proposedOverrides.entries) + canonicalSlot(entry.key): entry.value, + }; + await for (final slot in _slots(rule, through, budget)) { + if (excluded.contains(slot.key)) continue; + final override = mapped[slot.key]; + final value = + override ?? + base.copyWith( + id: slot.key, + scheduleTime: slot.civilTime, + timeZoneId: rule.timeZoneId, + occurrenceOffsetSeconds: slot.offsetSeconds, + ); + final instant = ScheduleTimeResolver.resolve( + value, + nowUtc: nowUtc, + ).instantUtc; + if (instant == null) { + throw const RecurrenceValidationException( + 'An occurrence needs explicit time review.', + ); + } + final preparation = proposedPreparationById[value.id] ?? basePreparation; + final lead = + preparation + + value.moveTime + + (value.scheduleSpareTime ?? Duration.zero); + proposed.add(( + slot, + TimeCorrectionBusyInterval(value, instant.subtract(lead), instant), + )); + budget.visit(); + if (proposed.length > BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + } + if (proposed.isEmpty) { + throw const RecurrenceValidationException( + 'No unexcluded occurrence remains.', + ); + } + final others = [...storedOthers]; + final uncertain = []; + // Materialized rows (including overrides) own their segment+slot even when + // their ID was preserved across a split and differs from a generated ID. + final occupied = { + for (final row in storedOthers) + if (row.schedule.recurringSegmentId != null && + row.schedule.recurringSlotKey != null) + '${row.schedule.recurringSegmentId}\n${canonicalSlot(row.schedule.recurringSlotKey!)}', + }; + final first = proposed + .map((e) => e.$2.preparationStartUtc) + .reduce((a, b) => a.isBefore(b) ? a : b); + final last = proposed + .map((e) => e.$2.instantUtc) + .reduce((a, b) => a.isAfter(b) ? a : b); + for (final segment in otherSegments) { + final keys = (otherExcludedSlots[segment.id] ?? {}) + .map(canonicalSlot) + .toSet(); + var upper = last.add(segment.leadTime + const Duration(days: 2)); + final max = DateTime.utc(9999, 12, 31, 23, 59, 59, 999, 999); + if (upper.isAfter(max)) upper = max; + await for (final slot in _slots( + segment.rule, + upper, + budget, + lead: segment.leadTime, + cutoff: segment.preparationNotBefore, + requireFullCount: false, + strictSelection: false, + onUnselected: (civil, offsets) { + final key = canonicalSlot(civil.toIso8601String()); + if (keys.contains(key) || occupied.contains('${segment.id}\n$key')) { + return; + } + if (civil.isBefore(segment.fromSlot) || + (segment.beforeSlot != null && + !civil.isBefore(segment.beforeSlot!))) { + return; + } + final early = CivilDateTime.fromFields( + civil, + ).atOffset(offsets.first).subtract(segment.leadTime); + final late = CivilDateTime.fromFields(civil).atOffset(offsets.last); + if (late.isBefore(first) || early.isAfter(last)) return; + budget.visit(); + if (uncertain.length >= BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + uncertain.add( + TimeCorrectionPossibleOverlap( + id: '${segment.id}\n${civil.toIso8601String()}', + name: segment.schedule.scheduleName, + reason: 'ambiguous', + originalCivil: civil, + timeZoneId: segment.rule.timeZoneId, + rule: segment.rule, + earliestPreparationUtc: early, + latestTargetUtc: late, + ), + ); + }, + )) { + if (!segment.includes(slot) || + keys.contains(slot.key) || + occupied.contains('${segment.id}\n${slot.key}')) { + continue; + } + final start = slot.instantUtc.subtract(segment.leadTime); + if (slot.instantUtc.isBefore(first) || start.isAfter(last)) continue; + final value = segment.schedule.copyWith( + id: '${segment.id}\n${slot.key}', + scheduleTime: slot.civilTime, + timeZoneId: segment.rule.timeZoneId, + occurrenceOffsetSeconds: slot.offsetSeconds, + recurringSegmentId: segment.id, + recurringSlotKey: slot.key, + recurringOrdinal: slot.ordinal, + ); + others.add(TimeCorrectionBusyInterval(value, start, slot.instantUtc)); + if (others.length > BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + } + } + proposed.sort( + (a, b) => a.$2.preparationStartUtc.compareTo(b.$2.preparationStartUtc), + ); + others.sort( + (a, b) => a.preparationStartUtc.compareTo(b.preparationStartUtc), + ); + final conflicts = []; + var otherStartIndex = 0; + final unboundedOthers = { + for (final segment in otherSegments) + if (segment.rule.count == null && + segment.rule.until == null && + segment.beforeSlot == null) + segment.id, + }; + final unbounded = rule.count == null && rule.until == null; + for (var i = 0; i < proposed.length; i++) { + final item = proposed[i]; + for ( + var j = i + 1; + j < proposed.length && + !proposed[j].$2.preparationStartUtc.isAfter(item.$2.instantUtc); + j++ + ) { + budget.visit(); + final other = proposed[j]; + if (_overlaps(item.$2, other.$2)) { + conflicts.add( + TimeCorrectionConflict( + slot: other.$1, + other: item.$2, + otherSlotKey: item.$1.key, + persistent: + unbounded && + !mapped.containsKey(item.$1.key) && + !mapped.containsKey(other.$1.key), + ), + ); + } + } + while (otherStartIndex < others.length && + !others[otherStartIndex].instantUtc.isAfter( + item.$2.preparationStartUtc, + ) && + others[otherStartIndex].instantUtc != item.$2.instantUtc) { + budget.visit(); + otherStartIndex++; + } + for (var j = otherStartIndex; j < others.length; j++) { + final other = others[j]; + budget.visit(); + if (other.preparationStartUtc.isAfter(item.$2.instantUtc)) break; + if (!_overlaps(item.$2, other)) continue; + conflicts.add( + TimeCorrectionConflict( + slot: item.$1, + other: other, + persistent: + unbounded && + !mapped.containsKey(item.$1.key) && + unboundedOthers.contains(other.schedule.recurringSegmentId) && + other.schedule.recurringOverrides.isEmpty, + ), + ); + } + if (conflicts.length > BackupLimits.records) { + BackupLimits.exceeded('records'); + } + } + return TimeCorrectionConflictProof( + conflicts: conflicts, + through: through, + workUnits: budget.work, + earliestPreparationUtc: first, + latestTargetUtc: last, + possibleOverlaps: uncertain, + proposedSlots: proposed.map((e) => e.$1).toList(), + ); + } +} diff --git a/lib/core/time/time_zone_rules.dart b/lib/core/time/time_zone_rules.dart new file mode 100644 index 00000000..7cdc618c --- /dev/null +++ b/lib/core/time/time_zone_rules.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +/// Shared initialization and content identity of the rules actually loaded. +abstract final class TimeZoneRules { + static bool _initialized = false; + + static void ensureInitialized() { + if (_initialized) return; + tz_data.initializeTimeZones(); + _initialized = true; + } + + static List get identifiers { + ensureInitialized(); + return List.unmodifiable( + tz.timeZoneDatabase.locations.keys.toList()..sort(), + ); + } + + static bool contains(String identifier) { + ensureInitialized(); + return identifier == 'UTC' || + tz.timeZoneDatabase.locations.containsKey(identifier); + } + + static tz.Location location(String identifier) { + ensureInitialized(); + return tz.getLocation(identifier); + } + + /// Do not cache by map or Location identity: the library mutates both maps + /// and rule arrays in place. Call at validation/claim boundaries, not ticks. + static String get loadedIdentity { + ensureInitialized(); + final names = tz.timeZoneDatabase.locations.keys.toList()..sort(); + return sha256 + .convert( + utf8.encode( + jsonEncode([ + for (final name in names) + [ + name, + tz.timeZoneDatabase.locations[name]!.name, + tz.timeZoneDatabase.locations[name]!.transitionAt, + tz.timeZoneDatabase.locations[name]!.transitionZone, + for (final zone in tz.timeZoneDatabase.locations[name]!.zones) + [zone.offset, zone.isDst, zone.abbreviation], + ], + ]), + ), + ) + .toString(); + } +} diff --git a/lib/core/utils/json_converters/duration_json_converters.dart b/lib/core/utils/json_converters/duration_json_converters.dart index 2ff5c9a9..0346d4b4 100644 --- a/lib/core/utils/json_converters/duration_json_converters.dart +++ b/lib/core/utils/json_converters/duration_json_converters.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:drift/drift.dart'; class DurationSqlConverter extends TypeConverter @@ -20,29 +21,9 @@ class CivilDateTimeSqlConverter extends TypeConverter const CivilDateTimeSqlConverter(); @override - DateTime fromSql(String fromDb) { - final parsed = DateTime.parse(fromDb); - return DateTime( - parsed.year, - parsed.month, - parsed.day, - parsed.hour, - parsed.minute, - parsed.second, - parsed.millisecond, - parsed.microsecond, - ); - } + DateTime fromSql(String fromDb) => CivilDateTime.parse(fromDb).toUtcCarrier(); @override - String toSql(DateTime value) => DateTime( - value.year, - value.month, - value.day, - value.hour, - value.minute, - value.second, - value.millisecond, - value.microsecond, - ).toIso8601String(); + String toSql(DateTime value) => + CivilDateTime.fromFields(value).toCivilIso8601String(); } diff --git a/lib/data/adapters/local_data_workflow_adapters.dart b/lib/data/adapters/local_data_workflow_adapters.dart index 59438abe..5ea32c0b 100644 --- a/lib/data/adapters/local_data_workflow_adapters.dart +++ b/lib/data/adapters/local_data_workflow_adapters.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/domain/ports/backup_time_review_port.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/backup/backup_service.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; @@ -8,7 +11,7 @@ import 'package:on_time_front/domain/ports/local_data_ports.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; @LazySingleton(as: BackupOperationsPort) -class LocalBackupAdapter implements BackupOperationsPort { +class LocalBackupAdapter implements BackupOperationsPort, BackupTimeReviewPort { LocalBackupAdapter(this._backup); final BackupService _backup; @override @@ -22,6 +25,12 @@ class LocalBackupAdapter implements BackupOperationsPort { throw const DataOperationException(DataOperationFailure.unavailable); } on DataOperationException { rethrow; + } on BackupRestoreCommitUncertain { + rethrow; + } on BackupProcessingFailure { + rethrow; + } on BackupProcessingCleanupFailure { + rethrow; } on FormatException { throw const DataOperationException(DataOperationFailure.invalidBackup); } catch (_) { @@ -38,6 +47,9 @@ class LocalBackupAdapter implements BackupOperationsPort { Future preview(String password) => _safe(() => _backup.selectAndPreviewRestore(password)); @override + Future selectForRestore(String password) => + _safe(() => _backup.selectAndReviewRestore(password)); + @override Future apply(BackupRestoreInput input) => _safe( () => _backup.applyRestoreWithReceipt(input as BackupRestoreCandidate), ); @@ -45,10 +57,12 @@ class LocalBackupAdapter implements BackupOperationsPort { @LazySingleton(as: RestoreDeliveryPort) class LocalRestoreDeliveryAdapter implements RestoreDeliveryPort { - LocalRestoreDeliveryAdapter(this._reconcile); + LocalRestoreDeliveryAdapter(this._reconcile, this._backup); + final BackupService _backup; final ReconcileAlarmsUseCase _reconcile; @override Future reconcile() async { + await _backup.finishRestoreCleanup(); final result = await _reconcile(); return (result.status == AlarmReconciliationStatus.armed || result.status == AlarmReconciliationStatus.disabled) && diff --git a/lib/data/daos/preparation_template_dao.dart b/lib/data/daos/preparation_template_dao.dart index f08d7401..9e981675 100644 --- a/lib/data/daos/preparation_template_dao.dart +++ b/lib/data/daos/preparation_template_dao.dart @@ -62,6 +62,25 @@ class PreparationTemplateDao extends DatabaseAccessor }); } + /// Validated backup materialization preserves both original timestamps. + Future restore({ + required String id, + required String name, + required PreparationEntity preparation, + required DateTime createdAt, + required DateTime updatedAt, + }) async { + await transaction(() async { + await put(id: id, name: name, preparation: preparation, now: updatedAt); + await (update(preparationTemplates)..where((t) => t.id.equals(id))).write( + PreparationTemplatesCompanion( + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ), + ); + }); + } + Future deleteById(String id) async { await (delete( preparationTemplates, diff --git a/lib/data/daos/schedule_dao.dart b/lib/data/daos/schedule_dao.dart index d091e938..564b907e 100644 --- a/lib/data/daos/schedule_dao.dart +++ b/lib/data/daos/schedule_dao.dart @@ -1,3 +1,5 @@ +import 'schedule_owned_content_cleanup.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_reference_policy.dart'; import 'package:on_time_front/domain/entities/schedule_not_found.dart'; import 'package:drift/drift.dart'; import 'package:on_time_front/data/tables/places_table.dart'; @@ -32,21 +34,22 @@ class ScheduleDao extends DatabaseAccessor } Future deleteSchedule(Schedule scheduleModel) async { - await (delete( - db.schedules, - )..where((tbl) => tbl.id.equals(scheduleModel.id))).go(); + await transaction(() => removeScheduleOwnedContent(db, scheduleModel.id)); } Future getScheduleById(String id) async { try { final query = await (select(db.schedules).join([ leftOuterJoin(db.places, db.places.id.equalsExp(db.schedules.placeId)), + leftOuterJoin( + db.recurringScheduleSegments, + db.recurringScheduleSegments.id.equalsExp( + db.schedules.recurringSegmentId, + ), + ), ])..where(db.schedules.id.equals(id))).getSingleOrNull(); if (query == null) throw ScheduleNotFound(id); - return ScheduleWithPlace( - schedule: query.readTable(db.schedules), - place: query.readTable(db.places), - ); + return _readJoined(query); } catch (e) { rethrow; } @@ -54,6 +57,7 @@ class ScheduleDao extends DatabaseAccessor SchedulesCompanion _withoutConcurrencyMetadata(SchedulesCompanion values) => values.copyWith( + requiresStartConfirmation: const Value.absent(), aggregateIncarnation: const Value.absent(), aggregateVersion: const Value.absent(), lastMutationId: const Value.absent(), @@ -97,6 +101,12 @@ class ScheduleDao extends DatabaseAccessor db.places, db.places.id.equalsExp(db.schedules.placeId), ), + leftOuterJoin( + db.recurringScheduleSegments, + db.recurringScheduleSegments.id.equalsExp( + db.schedules.recurringSegmentId, + ), + ), ])..where( db.schedules.scheduleTime.isBiggerOrEqualValue( const CivilDateTimeSqlConverter().toSql(startDate), @@ -111,40 +121,84 @@ class ScheduleDao extends DatabaseAccessor final List scheduleList = []; await Future.forEach(result, (schedule) async { - scheduleList.add( - ScheduleWithPlace( - schedule: schedule.readTable(db.schedules), - place: schedule.readTable(db.places), - ), - ); + scheduleList.add(_readJoined(schedule)); }); return scheduleList; } - Future> getScheduleList() async { + Future> getScheduleList({int? limit}) async { final query = select(db.schedules).join([ leftOuterJoin(db.places, db.places.id.equalsExp(db.schedules.placeId)), + leftOuterJoin( + db.recurringScheduleSegments, + db.recurringScheduleSegments.id.equalsExp( + db.schedules.recurringSegmentId, + ), + ), ]); + if (limit != null) query.limit(limit); final result = await query.get(); final List scheduleList = []; await Future.forEach(result, (schedule) async { - scheduleList.add( - ScheduleWithPlace( - schedule: schedule.readTable(db.schedules), - place: schedule.readTable(db.places), - ), - ); + scheduleList.add(_readJoined(schedule)); }); return scheduleList; } + /// Finite materialized references only; this query never expands a rule. + Future> getSeriesReferences( + String seriesId, { + required int limit, + }) async { + final query = + select(db.schedules).join([ + leftOuterJoin( + db.places, + db.places.id.equalsExp(db.schedules.placeId), + ), + innerJoin( + db.recurringScheduleSegments, + db.recurringScheduleSegments.id.equalsExp( + db.schedules.recurringSegmentId, + ), + ), + ]) + ..where(db.recurringScheduleSegments.seriesId.equals(seriesId)) + ..orderBy([OrderingTerm.asc(db.schedules.id)]) + ..limit(limit); + return (await query.get()).map(_readJoined).toList(); + } + + ScheduleWithPlace _readJoined(TypedResult row) { + final schedule = row.readTable(db.schedules); + final segment = row.readTableOrNull(db.recurringScheduleSegments); + return ScheduleWithPlace( + schedule: schedule, + place: row.readTable(db.places), + retainedRecurringReference: _retained( + schedule, + segment?.id, + segment?.beforeSlot, + ), + ); + } + + bool _retained(Schedule schedule, String? segmentId, String? before) { + if (schedule.recurringSegmentId == null) return false; + if (segmentId == null || schedule.recurringSlotKey == null) return true; + return RecurrenceReferencePolicy.isClosedTail( + schedule.recurringSlotKey!, + before, + ); + } + Stream> watchScheduleList() { // Trigger-written versions also change when a preparation dependency changes. // Drift needs those dependencies explicitly; notifications remain commit-bound. return db .customSelect( - 'SELECT s.*, p.id AS joined_place_id, p.place_name AS joined_place_name FROM schedules s JOIN places p ON p.id=s.place_id ORDER BY s.schedule_time ASC', + 'SELECT s.*, p.id AS joined_place_id, p.place_name AS joined_place_name, r.id AS joined_segment_id, r.before_slot AS joined_segment_before FROM schedules s JOIN places p ON p.id=s.place_id LEFT JOIN recurring_schedule_segments r ON r.id=s.recurring_segment_id ORDER BY s.schedule_time ASC', readsFrom: { db.schedules, db.places, @@ -164,6 +218,11 @@ class ScheduleDao extends DatabaseAccessor .map( (row) => ScheduleWithPlace( schedule: db.schedules.map(row.data), + retainedRecurringReference: _retained( + db.schedules.map(row.data), + row.readNullable('joined_segment_id'), + row.readNullable('joined_segment_before'), + ), place: Place( id: row.read('joined_place_id'), placeName: row.read('joined_place_name'), diff --git a/lib/data/daos/schedule_owned_content_cleanup.dart b/lib/data/daos/schedule_owned_content_cleanup.dart new file mode 100644 index 00000000..939b9b01 --- /dev/null +++ b/lib/data/daos/schedule_owned_content_cleanup.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/recurrence_codec.dart'; + +/// Transaction-internal cleanup of content owned by the rows being removed. +/// Never scans or collects unrelated pre-existing orphan content. +Future removeScheduleOwnedContent(AppDatabase db, String id) async { + final row = await removeScheduleRow(db, id); + if (row == null) return false; + if (row.preparationDefinitionId case final String definition) { + await collectUnusedScheduleDefinition(db, definition); + } + await collectUnusedSchedulePlace(db, row.placeId); + return true; +} + +/// Removes projection rows while preserving content ownership for replacement. +/// Explicit deletion uses removeScheduleOwnedContent instead. +Future removeScheduleRow(AppDatabase db, String id) async { + final row = await (db.select( + db.schedules, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (row == null) return null; + await (db.update( + db.preparationSchedules, + )..where((t) => t.scheduleId.equals(id))).write( + const PreparationSchedulesCompanion(nextPreparationId: Value(null)), + ); + await (db.delete( + db.preparationSchedules, + )..where((t) => t.scheduleId.equals(id))).go(); + await (db.delete(db.schedules)..where((t) => t.id.equals(id))).go(); + return row; +} + +Future collectUnusedScheduleDefinition( + AppDatabase db, + String candidate, +) async { + final unused = await db + .customSelect( + "SELECT id FROM preparation_definitions d WHERE id=? AND scope IN ('recurring','occurrence') AND NOT EXISTS(SELECT 1 FROM schedules s WHERE s.preparation_definition_id=d.id) AND NOT EXISTS(SELECT 1 FROM recurring_schedule_segments r WHERE r.preparation_id=d.id)", + variables: [Variable(candidate)], + ) + .get(); + if (unused.isEmpty) return; + await (db.delete( + db.preparationDefinitionSteps, + )..where((t) => t.definitionId.equals(candidate))).go(); + await (db.delete( + db.preparationDefinitions, + )..where((t) => t.id.equals(candidate))).go(); +} + +Future collectUnusedSchedulePlace( + AppDatabase db, + String candidate, +) async { + final schedules = + await (db.select(db.schedules) + ..where((t) => t.placeId.equals(candidate)) + ..limit(1)) + .get(); + if (schedules.isNotEmpty) return; + // Segments own their source Place even without any materialized occurrences. + // The reference is JSON, not a SQLite FK. Invalid content cannot prove orphanage. + for (final segment in await db.select(db.recurringScheduleSegments).get()) { + try { + final source = RecurrenceCodec.scheduleFromJson( + Map.from(jsonDecode(segment.scheduleJson) as Map), + ); + if (source.place.id == candidate) return; + } catch (_) { + return; + } + } + await (db.delete(db.places)..where((t) => t.id.equals(candidate))).go(); +} diff --git a/lib/data/data_sources/early_start_session_local_data_source.dart b/lib/data/data_sources/early_start_session_local_data_source.dart index 2baf6225..d676cdaa 100644 --- a/lib/data/data_sources/early_start_session_local_data_source.dart +++ b/lib/data/data_sources/early_start_session_local_data_source.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:convert'; import 'package:injectable/injectable.dart'; @@ -24,9 +25,15 @@ class EarlyStartSessionLocalDataSourceImpl required String scheduleId, required DateTime startedAt, }) async { + final identity = RestoreRuntimeIdentity.shared; + final incarnation = identity.storeIncarnation; final prefs = await SharedPreferences.getInstance(); final key = '$_prefsKeyPrefix$scheduleId'; - final payload = jsonEncode({'startedAt': startedAt.millisecondsSinceEpoch}); + final payload = jsonEncode({ + 'startedAt': startedAt.millisecondsSinceEpoch, + 'storeIncarnation': ?incarnation, + }); + if (!identity.accepts(incarnation)) throw StateError('Old runtime owner'); if (!await prefs.setString(key, payload)) { throw StateError('Early preparation start was not saved'); } @@ -41,6 +48,9 @@ class EarlyStartSessionLocalDataSourceImpl try { final map = jsonDecode(payload) as Map; + if (!RestoreRuntimeIdentity.shared.accepts(map['storeIncarnation'])) { + return null; + } final startedAtMillis = (map['startedAt'] as num?)?.toInt(); if (startedAtMillis == null) return null; return DateTime.fromMillisecondsSinceEpoch(startedAtMillis); @@ -53,6 +63,8 @@ class EarlyStartSessionLocalDataSourceImpl Future clearSession(String scheduleId) async { final prefs = await SharedPreferences.getInstance(); final key = '$_prefsKeyPrefix$scheduleId'; - await prefs.remove(key); + if (!await prefs.remove(key)) { + throw StateError('Early start cleanup pending'); + } } } diff --git a/lib/data/data_sources/preparation_with_time_local_data_source.dart b/lib/data/data_sources/preparation_with_time_local_data_source.dart index 3d02d4bc..a6ec75e1 100644 --- a/lib/data/data_sources/preparation_with_time_local_data_source.dart +++ b/lib/data/data_sources/preparation_with_time_local_data_source.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:convert'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; @@ -27,6 +28,8 @@ class PreparationWithTimeLocalDataSourceImpl String scheduleId, TimedPreparationSnapshotEntity snapshot, ) async { + final identity = RestoreRuntimeIdentity.shared; + final incarnation = identity.storeIncarnation; final prefs = await SharedPreferences.getInstance(); final key = '$_prefsKeyPrefix$scheduleId'; @@ -37,6 +40,7 @@ class PreparationWithTimeLocalDataSourceImpl } final jsonMap = { 'schemaVersion': 2, + 'storeIncarnation': ?incarnation, if (snapshot.requiresConfirmation) 'requiresConfirmation': true, 'savedAt': snapshot.savedAt.millisecondsSinceEpoch, 'startedAt': snapshot.startedAt?.millisecondsSinceEpoch, @@ -61,6 +65,7 @@ class PreparationWithTimeLocalDataSourceImpl .toList(), }; + if (!identity.accepts(incarnation)) throw StateError('Old runtime owner'); if (!await prefs.setString(key, jsonEncode(jsonMap))) { throw StateError('Preparation snapshot was not saved'); } @@ -77,6 +82,9 @@ class PreparationWithTimeLocalDataSourceImpl try { final Map map = jsonDecode(jsonString); + if (!RestoreRuntimeIdentity.shared.accepts(map['storeIncarnation'])) { + return null; + } final minimal = map['schemaVersion'] == 2; if (map['schemaVersion'] != null && !minimal) return null; final List steps = map['steps'] as List; diff --git a/lib/data/mappers/domain_persistence_mappers.dart b/lib/data/mappers/domain_persistence_mappers.dart index 9d88defb..329650cb 100644 --- a/lib/data/mappers/domain_persistence_mappers.dart +++ b/lib/data/mappers/domain_persistence_mappers.dart @@ -63,6 +63,7 @@ extension SchedulePersistenceMapper on ScheduleEntity { preparationTemplateName: preparationTemplateName, preparationTemplateDeleted: preparationTemplateDeleted, preparationFrozen: preparationFrozen, + requiresStartConfirmation: requiresStartConfirmation, scoreContributionRecorded: scoreContributionRecorded, recurringSegmentId: recurringSegmentId, recurringSlotKey: recurringSlotKey, @@ -105,6 +106,8 @@ extension ScheduleWithPlacePersistenceMapper on ScheduleWithPlace { preparationTemplateName: schedule.preparationTemplateName, preparationTemplateDeleted: schedule.preparationTemplateDeleted, preparationFrozen: schedule.preparationFrozen, + requiresStartConfirmation: schedule.requiresStartConfirmation, + retainedRecurringReference: retainedRecurringReference, scoreContributionRecorded: schedule.scoreContributionRecorded, recurringSegmentId: schedule.recurringSegmentId, recurringSlotKey: schedule.recurringSlotKey, @@ -128,6 +131,8 @@ extension UserPersistenceMapper on UserEntity { alarmsEnabled: true, alarmOffsetMinutes: 0, detailedNotificationContent: false, + restoreCleanupPending: false, + rejectLegacyDelivery: false, dataRevision: 0, lastDurableDataAt: null, ), diff --git a/lib/data/mappers/recurrence_codec.dart b/lib/data/mappers/recurrence_codec.dart index f92bf9c6..a7e0c36b 100644 --- a/lib/data/mappers/recurrence_codec.dart +++ b/lib/data/mappers/recurrence_codec.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; @@ -5,7 +6,7 @@ import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; abstract final class RecurrenceCodec { static Map ruleToJson(RecurrenceRule r) => { 'frequency': r.frequency.name, - 'start': r.start.toIso8601String(), + 'start': CivilDateTime.fromFields(r.start).toCivilIso8601String(), 'zone': r.timeZoneId, 'interval': r.interval, 'weekdays': r.weekdays.toList()..sort(), @@ -13,13 +14,15 @@ abstract final class RecurrenceCodec { 'monthDay': r.monthDay, 'ordinal': r.ordinal, 'monthWeekday': r.monthWeekday, - 'until': r.until?.toIso8601String(), + 'until': r.until == null + ? null + : CivilDateTime.fromFields(r.until!).toCivilIso8601String(), 'count': r.count, 'repeatedTime': r.repeatedTime?.name, }; static RecurrenceRule ruleFromJson(Map j) => RecurrenceRule( frequency: RecurrenceFrequency.values.byName(j['frequency'] as String), - start: DateTime.parse(j['start'] as String), + start: CivilDateTime.parse(j['start'] as String).toUtcCarrier(), timeZoneId: j['zone'] as String, interval: j['interval'] as int, weekdays: (j['weekdays'] as List).cast().toSet(), @@ -28,7 +31,9 @@ abstract final class RecurrenceCodec { ordinal: j['ordinal'] as int, monthWeekday: j['monthWeekday'] as int, count: j['count'] as int?, - until: j['until'] == null ? null : DateTime.parse(j['until'] as String), + until: j['until'] == null + ? null + : CivilDateTime.parse(j['until'] as String).toUtcCarrier(), repeatedTime: j['repeatedTime'] == null ? null : RepeatedCivilTime.values.byName(j['repeatedTime'] as String), @@ -38,7 +43,7 @@ abstract final class RecurrenceCodec { 'placeId': s.place.id, 'place': s.place.placeName, 'name': s.scheduleName, - 'time': s.scheduleTime.toIso8601String(), + 'time': CivilDateTime.fromFields(s.scheduleTime).toCivilIso8601String(), 'zone': s.timeZoneId, 'offset': s.occurrenceOffsetSeconds, 'move': s.moveTime.inMinutes, @@ -53,7 +58,7 @@ abstract final class RecurrenceCodec { placeName: j['place'] as String, ), scheduleName: j['name'] as String, - scheduleTime: DateTime.parse(j['time'] as String), + scheduleTime: CivilDateTime.parse(j['time'] as String).toUtcCarrier(), timeZoneId: j['zone'] as String, occurrenceOffsetSeconds: j['offset'] as int?, moveTime: Duration(minutes: j['move'] as int), diff --git a/lib/data/repositories/alarm_repository_impl.dart b/lib/data/repositories/alarm_repository_impl.dart index bb0b015e..c2ff132e 100644 --- a/lib/data/repositories/alarm_repository_impl.dart +++ b/lib/data/repositories/alarm_repository_impl.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; @@ -41,9 +42,11 @@ class AlarmRepositoryImpl implements AlarmRepository { Future updateAlarmSettings({ required bool alarmsEnabled, }) async { - await _userDao.updateAlarmSettings( - userId: localProfileId, - enabled: alarmsEnabled, + await _database.writeTransaction( + () => _userDao.updateAlarmSettings( + userId: localProfileId, + enabled: alarmsEnabled, + ), ); return getAlarmSettings(); } @@ -55,6 +58,7 @@ class AlarmRepositoryImpl implements AlarmRepository { ) async { // Reconciliation has a long search horizon but the platform can only hold // a bounded pending set. Supply the nearest candidates from each series. + final evaluationNow = DateTime.now().toUtc(); final civilStart = startDate.subtract(const Duration(days: 2)); await _recurring?.materialize(civilStart, endDate, perSeriesLimit: 64); return _database.transaction(() async { @@ -64,11 +68,16 @@ class AlarmRepositoryImpl implements AlarmRepository { )).map((r) => r.toScheduleEntity()); final result = []; for (final schedule in schedules) { + if (schedule.retainedRecurringReference) continue; final preparation = await readSchedulePreparation(_database, schedule); result.add( ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: evaluationNow, + ), ), ); } diff --git a/lib/data/repositories/default_preferences_repository_impl.dart b/lib/data/repositories/default_preferences_repository_impl.dart new file mode 100644 index 00000000..8a6f4076 --- /dev/null +++ b/lib/data/repositories/default_preferences_repository_impl.dart @@ -0,0 +1,211 @@ +import 'package:drift/drift.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/default_preferences_repository.dart'; + +@Injectable(as: DefaultPreferencesRepository) +class DefaultPreferencesRepositoryImpl implements DefaultPreferencesRepository { + DefaultPreferencesRepositoryImpl( + this.db, { + @ignoreParam LocalDataOperationGate? gate, + }) : gate = gate ?? LocalDataOperationGate.shared; + final AppDatabase db; + final LocalDataOperationGate gate; + + @override + bool isGenerationCurrent(int generation) => + generation == gate.generation && + !gate.isReplacingData && + !gate.isRecoveryPending && + !gate.isInvalidated; + + Future _profile() async { + final row = await (db.select( + db.users, + )..where((u) => u.id.equals(localProfileId))).getSingleOrNull(); + if (row?.storeIncarnation == null || row!.restoreCleanupPending) { + throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.unavailable, + ); + } + return row; + } + + @override + Future read() async { + final generation = gate.captureWrite(); + return db.transaction(() async { + gate.checkWrite(generation); + final user = await _profile(); + final preparation = await db.preparationUserDao + .getPreparationUsersByUserId(localProfileId); + gate.checkWrite(generation); + return DefaultPreferencesSnapshot( + preparation: PreparationEntity( + preparationStepList: List.unmodifiable( + preparation.preparationStepList, + ), + ), + spareTime: Duration(minutes: user.spareTime), + store: user.storeIncarnation!, + generation: generation, + revision: user.dataRevision, + ); + }); + } + + @override + int get generation => gate.generation; + + @override + Future authority( + DefaultPreferencesSaveReceipt receipt, + ) async { + if (receipt.generation != gate.generation) { + return DefaultPreferencesAuthority.superseded; + } + if (!isGenerationCurrent(receipt.generation)) { + return DefaultPreferencesAuthority.unavailable; + } + try { + final row = await (db.select( + db.users, + )..where((u) => u.id.equals(localProfileId))).getSingleOrNull(); + if (receipt.generation != gate.generation) { + return DefaultPreferencesAuthority.superseded; + } + if (!isGenerationCurrent(receipt.generation) || + row == null || + row.restoreCleanupPending || + row.storeIncarnation == null) { + return DefaultPreferencesAuthority.unavailable; + } + return row.storeIncarnation == receipt.store + ? DefaultPreferencesAuthority.current + : DefaultPreferencesAuthority.superseded; + } catch (_) { + return receipt.generation != gate.generation + ? DefaultPreferencesAuthority.superseded + : DefaultPreferencesAuthority.unavailable; + } + } + + @override + Future save( + DefaultPreferencesSubmission input, + ) async { + _validate(input); + try { + return await db.writeTransaction(() async { + gate.checkWrite(input.baseline.generation); + final row = await _profile(); + if (row.storeIncarnation != input.baseline.store || + row.dataRevision != input.baseline.revision) { + throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.conflict, + ); + } + final current = await db.preparationUserDao.getPreparationUsersByUserId( + localProfileId, + ); + final preparationChanged = !_same(current, input.preparation); + final spareChanged = row.spareTime != input.spareTime.inMinutes; + final changed = preparationChanged || spareChanged; + if (preparationChanged) { + await db.preparationUserDao.createPreparationUser( + input.preparation.ordered, + localProfileId, + ); + } + if (spareChanged) { + // This aggregate owns the one durable revision for both preferences. + await (db.update( + db.users, + )..where((u) => u.id.equals(localProfileId))).write( + UsersCompanion(spareTime: Value(input.spareTime.inMinutes)), + ); + } + if (changed) await db.userDao.markDurableDataChanged(localProfileId); + return DefaultPreferencesSaveReceipt( + operation: Object(), + store: row.storeIncarnation!, + generation: input.baseline.generation, + changed: changed, + revision: row.dataRevision + (changed ? 1 : 0), + reloadPending: changed, + deliveryPending: changed, + ); + }, gate: gate); + } on DefaultPreferencesRejected { + rethrow; + } on LocalDataUnavailable { + throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.unavailable, + ); + } catch (_) { + throw const DefaultPreferencesRejected(DefaultPreferencesFailure.failed); + } + } + + bool _same(PreparationEntity a, PreparationEntity b) { + final left = a.ordered.preparationStepList; + final right = b.ordered.preparationStepList; + if (left.length != right.length) return false; + for (var i = 0; i < left.length; i++) { + if (left[i].id != right[i].id || + left[i].preparationName != right[i].preparationName || + left[i].preparationTime != right[i].preparationTime) { + return false; + } + } + return true; + } + + void _validate(DefaultPreferencesSubmission input) { + Never invalid() => throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.invalid, + ); + final spare = input.spareTime; + if (spare.isNegative || + spare.inMinutes > LocalInputLimits.maxMinuteValue || + spare.inMicroseconds % Duration.microsecondsPerMinute != 0) { + invalid(); + } + final steps = input.preparation.preparationStepList; + final byId = {for (final step in steps) step.id: step}; + if (byId.length != steps.length) invalid(); + final targets = {}; + for (final step in steps) { + if (step.id.isEmpty || + step.preparationName.trim().isEmpty || + step.preparationName.length > 30 || + step.preparationTime.inMinutes < 1 || + step.preparationTime.inMinutes > LocalInputLimits.maxMinuteValue || + step.preparationTime.inMicroseconds % + Duration.microsecondsPerMinute != + 0) { + invalid(); + } + if (step.nextPreparationId != null && + (!byId.containsKey(step.nextPreparationId) || + !targets.add(step.nextPreparationId!))) { + invalid(); + } + final seen = {}; + var current = step; + while (true) { + if (!seen.add(current.id)) invalid(); + if (current.nextPreparationId == null) break; + final next = byId[current.nextPreparationId]; + if (next == null) invalid(); + current = next; + } + } + if (targets.isNotEmpty && targets.length != steps.length - 1) invalid(); + } +} diff --git a/lib/data/repositories/nearest_schedule_query_repository_impl.dart b/lib/data/repositories/nearest_schedule_query_repository_impl.dart new file mode 100644 index 00000000..4f83e962 --- /dev/null +++ b/lib/data/repositories/nearest_schedule_query_repository_impl.dart @@ -0,0 +1,483 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; +import 'package:on_time_front/domain/repositories/recurring_schedule_repository.dart'; + +@Singleton(as: NearestScheduleQueryRepository) +class NearestScheduleQueryRepositoryImpl + implements NearestScheduleQueryRepository { + NearestScheduleQueryRepositoryImpl( + this.db, + this.recurring, { + @ignoreParam DateTime Function()? now, + @ignoreParam LocalDataOperationGate? gate, + @ignoreParam this.totalBudget = 200000, + }) : now = now ?? DateTime.now, + gate = gate ?? LocalDataOperationGate.shared; + final AppDatabase db; + final RecurringScheduleRepository recurring; + final DateTime Function() now; + final LocalDataOperationGate gate; + final int totalBudget; + + @override + Stream get changes => db.scheduleDao.watchScheduleList().map((_) {}); + + Future _signature() async { + final parts = []; + for (final tableName in [ + db.users.actualTableName, + db.schedules.actualTableName, + db.places.actualTableName, + db.preparationSchedules.actualTableName, + db.preparationUsers.actualTableName, + db.preparationTemplates.actualTableName, + db.preparationTemplateSteps.actualTableName, + db.preparationDefinitions.actualTableName, + db.preparationDefinitionSteps.actualTableName, + db.recurringScheduleSegments.actualTableName, + db.recurringScheduleExclusions.actualTableName, + ]) { + final rows = await db.customSelect('SELECT * FROM $tableName').get(); + final encoded = rows.map((row) => jsonEncode(row.data)).toList()..sort(); + parts.add(encoded); + } + return sha256.convert(utf8.encode(jsonEncode(parts))).toString(); + } + + Future _profile() => (db.select( + db.users, + )..where((t) => t.id.equals(localProfileId))).getSingle(); + + @override + Future open(NearestQueryKey key) async { + final evaluated = now().toUtc(); + final rules = TimeZoneRules.loadedIdentity; + return db.transaction(() async { + gate.checkWrite(key.generation); + final profile = await _profile(); + final stored = (await db.scheduleDao.getScheduleList()) + .map((v) => v.toScheduleEntity()) + .toList(); + final segments = await recurring.getSegments(); + final exclusions = await db.select(db.recurringScheduleExclusions).get(); + final signature = await _signature(); + gate.checkWrite(key.generation); + return _Session( + this, + key, + evaluated, + rules, + profile.storeIncarnation!, + profile.dataRevision, + signature, + stored, + segments, + { + for (final segment in segments) + segment.id: exclusions + .where((v) => v.segmentId == segment.id) + .map((v) => v.slotKey) + .toSet(), + }, + ); + }); + } + + Future _readPreparation( + ScheduleEntity selected, + DateTime evaluated, + ) async { + final raw = await (db.select( + db.schedules, + )..where((t) => t.id.equals(selected.id))).getSingleOrNull(); + if (raw == null) throw const NearestQueryInvalidated(); + final actual = (await db.scheduleDao.getScheduleById( + selected.id, + )).toScheduleEntity(); + if (actual.preparationDefinitionId != null) { + final exists = + await (db.select(db.preparationDefinitions) + ..where((t) => t.id.equals(actual.preparationDefinitionId!))) + .getSingleOrNull(); + if (exists == null) throw const _MissingPreparation(); + } + final preparation = await readSchedulePreparation(db, actual); + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + actual, + PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve(actual, nowUtc: evaluated), + ); + } + + @override + Future readActive() => db.transaction( + () async { + final generation = gate.generation; + gate.checkWrite(generation); + final rows = (await db.scheduleDao.getScheduleList()) + .map((v) => v.toScheduleEntity()) + .where(_active) + .toList(); + final evaluated = now().toUtc(); + for (final row in rows) { + final resolution = ScheduleTimeResolver.resolve(row, nowUtc: evaluated); + if (resolution.instantUtc == null) { + throw ScheduleTimeUnresolved(resolution.status); + } + } + rows.sort((a, b) { + final comparison = a.startedAt!.compareTo(b.startedAt!); + return comparison != 0 ? comparison : a.id.compareTo(b.id); + }); + final result = rows.isEmpty + ? null + : await _readPreparation(rows.first, evaluated); + gate.checkWrite(generation); + return result; + }, + ); +} + +bool _active(ScheduleEntity s) => + s.isStarted && + s.startedAt != null && + s.preparationFrozen && + s.doneStatus == ScheduleDoneStatus.notEnded; + +final class _MissingPreparation implements Exception { + const _MissingPreparation(); +} + +final class _Probe { + _Probe(this.segment, this.iterator, this.maximumOffset); + final RecurringSegment segment; + final Iterator iterator; + final Duration maximumOffset; + DateTime? frontier; + bool complete = false; + bool outOfRange = false; + bool proves(DateTime? target) => + complete || + (target != null && + frontier != null && + frontier!.subtract(maximumOffset).isAfter(target)); +} + +final class _Session implements NearestScheduleQuerySession { + _Session( + this.repository, + this.key, + this.evaluated, + this.rules, + this.store, + this.revision, + this.signature, + List stored, + List segments, + Map> exclusions, + ) { + candidates.addAll(stored); + final ownedSlots = { + for (final row in stored) + if (row.recurringSegmentId != null && row.recurringSlotKey != null) + '${row.recurringSegmentId}:${row.recurringSlotKey}', + }; + for (final segment in segments) { + if (!TimeZoneRules.contains(segment.rule.timeZoneId)) { + issues.add( + NearestQueryIssue( + reason: NearestQueryIssueReason.unknownTimeZone, + segmentId: segment.id, + ), + ); + continue; + } + final zones = TimeZoneRules.location(segment.rule.timeZoneId).zones; + final maxOffset = zones + .map((z) => z.offset) + .fold(-86400000, (a, b) => a > b ? a : b); + final excluded = { + ...?exclusions[segment.id], + for (final row in stored) + if (row.recurringSegmentId == segment.id && + row.recurringSlotKey != null && + ownedSlots.contains('${segment.id}:${row.recurringSlotKey}')) + row.recurringSlotKey!, + }; + probes.add( + _Probe( + segment, + const RecurrenceEngine() + .scan( + segment.rule, + through: segment.beforeSlot ?? DateTime.utc(9999, 12, 31), + from: segment.fromSlot, + preparationNotBeforeUtc: segment.preparationNotBefore, + leadTime: segment.leadTime, + excludedKeys: excluded, + ) + .iterator, + Duration(milliseconds: maxOffset), + ), + ); + } + } + final NearestScheduleQueryRepositoryImpl repository; + final NearestQueryKey key; + final DateTime evaluated; + DateTime? publicationFloor; + final String rules; + final String store; + int revision; + String signature; + final candidates = []; + final generated = {}; + final probes = <_Probe>[]; + final issues = []; + int visited = 0; + int nextProbe = 0; + bool cancelled = false; + @override + void cancel() { + cancelled = true; + candidates.clear(); + generated.clear(); + probes.clear(); + } + + void check() { + if (cancelled || + key.generation != repository.gate.generation || + !repository.gate.isAvailable) { + throw const NearestQueryInvalidated(); + } + } + + List<(ScheduleEntity, ScheduleTimeResolution)> eligible(DateTime now) { + final found = <(ScheduleEntity, ScheduleTimeResolution)>[]; + for (final row in candidates) { + if (row.doneStatus != ScheduleDoneStatus.notEnded || + row.retainedRecurringReference || + _active(row)) { + continue; + } + final resolved = ScheduleTimeResolver.resolve(row, nowUtc: now); + if (resolved.instantUtc == null) { + if (resolved.isHistorical) continue; + final reason = switch (resolved.status) { + ScheduleTimeResolutionStatus.unknownZone => + NearestQueryIssueReason.unknownTimeZone, + ScheduleTimeResolutionStatus.ambiguous => + NearestQueryIssueReason.ambiguousOccurrence, + ScheduleTimeResolutionStatus.nonexistent => + NearestQueryIssueReason.nonexistentCivilTime, + ScheduleTimeResolutionStatus.changed => + NearestQueryIssueReason.changedTimeRules, + ScheduleTimeResolutionStatus.historicalUncertain => + NearestQueryIssueReason.uncertainHistoricalTime, + _ => NearestQueryIssueReason.invalidTimeMetadata, + }; + final issue = NearestQueryIssue(reason: reason, scheduleId: row.id); + if (!issues.contains(issue)) issues.add(issue); + continue; + } + if (!resolved.instantUtc!.isBefore(now)) found.add((row, resolved)); + } + found.sort((a, b) { + final time = a.$2.instantUtc!.compareTo(b.$2.instantUtc!); + return time != 0 ? time : a.$1.id.compareTo(b.$1.id); + }); + return found; + } + + NearestQueryProgress progress(DateTime? target) => NearestQueryProgress( + visitedCandidates: visited, + candidateBudget: repository.totalBudget, + provenSegments: probes.where((p) => p.proves(target)).length, + totalSegments: probes.length, + ); + @override + Future advance({required int candidateBudget}) async { + check(); + var found = eligible(publicationFloor ?? evaluated); + DateTime? target = found.isEmpty ? null : found.first.$2.instantUtc; + var remaining = candidateBudget; + while (remaining > 0 && + visited < repository.totalBudget && + probes.any((p) => !p.proves(target) && !p.outOfRange)) { + final probe = probes[nextProbe++ % probes.length]; + if (probe.proves(target) || probe.outOfRange) continue; + remaining--; + visited++; + try { + if (!probe.iterator.moveNext()) { + probe.complete = + probe.segment.beforeSlot != null || + probe.segment.rule.until != null; + probe.outOfRange = !probe.complete; + continue; + } + final step = probe.iterator.current; + probe.frontier = step.frontierCivil; + if (probe.segment.beforeSlot != null && + !step.frontierCivil.isBefore(probe.segment.beforeSlot!)) { + probe.complete = true; + continue; + } + if (step.countComplete) probe.complete = true; + final slot = step.slot; + if (slot != null && + probe.segment.includes(slot) && + !slot.instantUtc.isBefore(evaluated)) { + final value = repository.recurring.candidateFor(probe.segment, slot); + candidates.add(value); + generated[value.id] = (probe, slot); + found = eligible(publicationFloor ?? evaluated); + target = found.isEmpty ? null : found.first.$2.instantUtc; + } + } on RepeatedTimeChoiceRequired { + issues.add( + NearestQueryIssue( + reason: NearestQueryIssueReason.ambiguousOccurrence, + segmentId: probe.segment.id, + ), + ); + probe.complete = true; + } + } + check(); + found = eligible(publicationFloor ?? evaluated); + target = found.isEmpty ? null : found.first.$2.instantUtc; + final currentProgress = progress(target); + if (probes.any((p) => !p.proves(target))) { + final exhausted = visited >= repository.totalBudget; + final range = probes.any((p) => p.outOfRange && !p.proves(target)); + return NearestQueryLimited( + key: key, + reason: exhausted + ? NearestQueryLimitReason.searchBudgetExhausted + : range + ? NearestQueryLimitReason.representableRangeExhausted + : NearestQueryLimitReason.interrupted, + progress: currentProgress, + canContinue: !exhausted && !range, + canRetry: false, + issues: List.unmodifiable(issues), + ); + } + if (TimeZoneRules.loadedIdentity != rules) { + throw const NearestQueryInvalidated(); + } + try { + return await repository.db.writeTransaction(() async { + check(); + if (await repository._signature() != signature) { + throw const NearestQueryInvalidated(); + } + check(); + final now = repository.now().toUtc(); + found = eligible(now); + target = found.isEmpty ? null : found.first.$2.instantUtc; + if (probes.any((p) => !p.proves(target))) { + publicationFloor = now; + return NearestQueryLimited( + key: key, + reason: NearestQueryLimitReason.interrupted, + progress: progress(target), + canContinue: true, + canRetry: false, + issues: List.unmodifiable(issues), + ); + } + if (found.isEmpty && issues.isNotEmpty) { + return NearestQueryError( + key: key, + reason: NearestQueryFailureReason.recurrenceMetadataInvalid, + issues: List.unmodifiable(issues), + ); + } + ScheduleWithPreparationEntity? selected; + if (found.isNotEmpty) { + var value = found.first.$1; + final created = generated[value.id]; + if (created != null) { + value = await repository.recurring.materializeCandidate( + created.$1.segment, + created.$2, + ); + } + try { + selected = await repository._readPreparation(value, now); + } on _MissingPreparation { + throw const _PreparationReadFailure( + NearestQueryFailureReason.preparationMissing, + ); + } on NearestQueryInvalidated { + rethrow; + } catch (_) { + throw const _PreparationReadFailure( + NearestQueryFailureReason.preparationReadFailed, + ); + } + } + check(); + if (TimeZoneRules.loadedIdentity != rules) { + throw const NearestQueryInvalidated(); + } + final profile = await repository._profile(); + revision = profile.dataRevision; + signature = await repository._signature(); + final verifiedNow = repository.now().toUtc(); + if (selected != null && + (selected.timeResolution?.instantUtc == null || + selected.timeResolution!.instantUtc!.isBefore(verifiedNow))) { + throw const NearestQueryInvalidated(); + } + final authority = NearestQueryAuthority( + key: key, + storeIncarnation: store, + dataRevision: revision, + ruleDataIdentity: rules, + evaluatedAtUtc: evaluated, + verifiedAtUtc: repository.now().toUtc(), + ); + return selected == null + ? NearestQueryEmpty(authority: authority, issues: issues) + : NearestQueryReady( + value: NearestVerifiedSchedule( + schedule: selected, + resolution: selected.timeResolution!, + authority: authority, + ), + issues: issues, + ); + }, gate: repository.gate); + } on _PreparationReadFailure catch (failure) { + return NearestQueryError( + key: key, + reason: failure.reason, + issues: List.unmodifiable(issues), + ); + } + } +} + +final class _PreparationReadFailure implements Exception { + const _PreparationReadFailure(this.reason); + final NearestQueryFailureReason reason; +} diff --git a/lib/data/repositories/preparation_repository_impl.dart b/lib/data/repositories/preparation_repository_impl.dart index 7b936b78..d1921aac 100644 --- a/lib/data/repositories/preparation_repository_impl.dart +++ b/lib/data/repositories/preparation_repository_impl.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/core/startup/subscription_cleanup.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'dart:async'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/constants/local_profile.dart'; @@ -9,7 +12,13 @@ import 'package:on_time_front/domain/repositories/preparation_repository.dart'; import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:rxdart/subjects.dart'; -@Singleton(as: PreparationRepository) +Future disposePreparationRepository(PreparationRepository resource) => + StartupDependencyScope.release( + resource, + (resource as PreparationRepositoryImpl).dispose, + ); + +@Singleton(as: PreparationRepository, dispose: disposePreparationRepository) class PreparationRepositoryImpl implements PreparationRepository { PreparationRepositoryImpl({ required PreparationLocalDataSource preparationLocalDataSource, @@ -19,23 +28,69 @@ class PreparationRepositoryImpl implements PreparationRepository { _userRepository = userRepository, _userDao = database.userDao, _database = database { - _subscription = database + StartupDependencyScope.own(this, dispose); + LocalDataOperationGate.shared.addListener(_observeCurrentGeneration); + _observeCurrentGeneration(); + } + + StreamSubscription>? _subscription; + int _watchGeneration = -1; + Object? _subscriptionOwner; + + bool _isCurrentWatch(Object owner, int generation) { + final gate = LocalDataOperationGate.shared; + return identical(owner, _subscriptionOwner) && + generation == gate.generation && + !gate.isReplacingData && + !gate.isRecoveryPending && + !gate.isInvalidated; + } + + final _retiredWatches = SubscriptionCleanup(); + bool _disposed = false; + Future? _subjectClose; + Future? _disposeFlight; + void _observeCurrentGeneration() { + if (_disposed) return; + final gate = LocalDataOperationGate.shared; + if (_watchGeneration != gate.generation || + gate.isReplacingData || + gate.isRecoveryPending || + gate.isInvalidated) { + // Retire the token before cancellation finishes: old reads and errors + // cannot publish into the replacement installation. + _subscriptionOwner = null; + _retiredWatches.retire(_subscription); + _subscription = null; + _preparationStreamController.add(const {}); + } + if (_subscription != null || + gate.isReplacingData || + gate.isRecoveryPending || + gate.isInvalidated) { + return; + } + final generation = gate.generation; + final owner = Object(); + _watchGeneration = generation; + _subscriptionOwner = owner; + _subscription = _database .customSelect( 'SELECT count(*) AS n FROM schedules', readsFrom: { - database.schedules, - database.preparationSchedules, - database.preparationDefinitions, - database.preparationDefinitionSteps, - database.preparationUsers, - database.preparationTemplates, - database.preparationTemplateSteps, + _database.schedules, + _database.preparationSchedules, + _database.preparationDefinitions, + _database.preparationDefinitionSteps, + _database.preparationUsers, + _database.preparationTemplates, + _database.preparationTemplateSteps, }, ) .watch() .asyncMap( - (_) => database.transaction(() async { - final schedules = await database.select(database.schedules).get(); + (_) => _database.transaction(() async { + final schedules = await _database.select(_database.schedules).get(); return { for (final schedule in schedules) schedule.id: await _localDataSource.getPreparationByScheduleId( @@ -45,12 +100,19 @@ class PreparationRepositoryImpl implements PreparationRepository { }), ) .listen( - _preparationStreamController.add, - onError: _preparationStreamController.addError, + (value) { + if (_isCurrentWatch(owner, generation)) { + _preparationStreamController.add(value); + } + }, + onError: (Object error, StackTrace stack) { + if (_isCurrentWatch(owner, generation)) { + _preparationStreamController.addError(error, stack); + } + }, ); } - late final StreamSubscription> _subscription; final PreparationLocalDataSource _localDataSource; final UserRepository _userRepository; final UserDao _userDao; @@ -68,8 +130,8 @@ class PreparationRepositoryImpl implements PreparationRepository { required Duration spareTime, required String note, }) async { - await _userRepository.getUser(); - await _database.transaction(() async { + await _database.writeTransaction(() async { + await _userRepository.getUser(); final existing = await _localDataSource.getDefaultPreparation( localProfileId, ); @@ -94,22 +156,28 @@ class PreparationRepositoryImpl implements PreparationRepository { PreparationEntity preparationEntity, String scheduleId, ) async { - await _database.transaction(() async { + final generation = LocalDataOperationGate.shared.generation; + await _database.writeTransaction(() async { await _localDataSource.createCustomPreparation( preparationEntity, scheduleId, ); await _userDao.markDurableDataChanged(localProfileId); }); - _emitSchedulePreparation(scheduleId, preparationEntity); + if (generation == LocalDataOperationGate.shared.generation) { + _emitSchedulePreparation(scheduleId, preparationEntity); + } } @override Future getPreparationByScheduleId(String scheduleId) async { + final generation = LocalDataOperationGate.shared.generation; final preparation = await _localDataSource.getPreparationByScheduleId( scheduleId, ); - _emitSchedulePreparation(scheduleId, preparation); + if (generation == LocalDataOperationGate.shared.generation) { + _emitSchedulePreparation(scheduleId, preparation); + } } @override @@ -121,11 +189,13 @@ class PreparationRepositoryImpl implements PreparationRepository { Future updateDefaultPreparation( PreparationEntity preparationEntity, ) async { - await _localDataSource.replaceDefaultPreparation( - preparationEntity, - userId: localProfileId, - ); - await _userDao.markDurableDataChanged(localProfileId); + await _database.writeTransaction(() async { + await _localDataSource.replaceDefaultPreparation( + preparationEntity, + userId: localProfileId, + ); + await _userDao.markDurableDataChanged(localProfileId); + }); } @override @@ -133,14 +203,17 @@ class PreparationRepositoryImpl implements PreparationRepository { PreparationEntity preparationEntity, String scheduleId, ) async { - await _database.transaction(() async { + final generation = LocalDataOperationGate.shared.generation; + await _database.writeTransaction(() async { await _localDataSource.replaceSchedulePreparation( preparationEntity, scheduleId: scheduleId, ); await _userDao.markDurableDataChanged(localProfileId); }); - _emitSchedulePreparation(scheduleId, preparationEntity); + if (generation == LocalDataOperationGate.shared.generation) { + _emitSchedulePreparation(scheduleId, preparationEntity); + } } @override @@ -166,14 +239,24 @@ class PreparationRepositoryImpl implements PreparationRepository { String scheduleId, PreparationEntity preparation, ) { + if (_disposed) return; _preparationStreamController.add({ ..._preparationStreamController.value, scheduleId: preparation, }); } - Future dispose() async { - await _subscription.cancel(); - await _preparationStreamController.close(); + Future dispose() => + _disposeFlight ??= _dispose().whenComplete(() => _disposeFlight = null); + Future _dispose() async { + _disposed = true; + LocalDataOperationGate.shared.removeListener(_observeCurrentGeneration); + _subscriptionOwner = null; + _retiredWatches.retire(_subscription); + _subscription = null; + await Future.wait([ + _retiredWatches.close(), + _subjectClose ??= _preparationStreamController.close(), + ]); } } diff --git a/lib/data/repositories/preparation_template_repository_impl.dart b/lib/data/repositories/preparation_template_repository_impl.dart index 97fd3b9c..c39f9747 100644 --- a/lib/data/repositories/preparation_template_repository_impl.dart +++ b/lib/data/repositories/preparation_template_repository_impl.dart @@ -11,9 +11,11 @@ import 'package:on_time_front/domain/repositories/preparation_template_repositor class PreparationTemplateRepositoryImpl implements PreparationTemplateRepository { PreparationTemplateRepositoryImpl(AppDatabase database) - : _dao = database.preparationTemplateDao, + : _database = database, + _dao = database.preparationTemplateDao, _userDao = database.userDao; + final AppDatabase _database; final PreparationTemplateDao _dao; final UserDao _userDao; @@ -31,13 +33,15 @@ class PreparationTemplateRepositoryImpl required String templateName, required PreparationEntity preparation, }) async { - await _dao.put( - id: templateId, - name: templateName, - preparation: preparation, - now: DateTime.now(), - ); - await _userDao.markDurableDataChanged(localProfileId); + await _database.writeTransaction(() async { + await _dao.put( + id: templateId, + name: templateName, + preparation: preparation, + now: DateTime.now(), + ); + await _userDao.markDurableDataChanged(localProfileId); + }); } @override @@ -53,7 +57,9 @@ class PreparationTemplateRepositoryImpl @override Future deletePreparationTemplate(String templateId) async { - await _dao.deleteById(templateId); - await _userDao.markDurableDataChanged(localProfileId); + await _database.writeTransaction(() async { + await _dao.deleteById(templateId); + await _userDao.markDurableDataChanged(localProfileId); + }); } } diff --git a/lib/data/repositories/recurring_schedule_repository_impl.dart b/lib/data/repositories/recurring_schedule_repository_impl.dart index bb99c955..170791b4 100644 --- a/lib/data/repositories/recurring_schedule_repository_impl.dart +++ b/lib/data/repositories/recurring_schedule_repository_impl.dart @@ -1,3 +1,8 @@ +import 'package:on_time_front/data/daos/schedule_owned_content_cleanup.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_reference_policy.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:on_time_front/domain/entities/schedule_save.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/core/time/civil_time_resolver.dart'; @@ -51,8 +56,8 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { schedule: RecurrenceCodec.scheduleFromJson(jsonDecode(r.scheduleJson)), preparation: await getPreparation(r.preparationId), preparationId: r.preparationId, - fromSlot: DateTime.parse(r.fromSlot), - beforeSlot: r.beforeSlot == null ? null : DateTime.parse(r.beforeSlot!), + fromSlot: _civilSlot(r.fromSlot), + beforeSlot: r.beforeSlot == null ? null : _civilSlot(r.beforeSlot!), createdAt: r.createdAt, preparationNotBefore: r.preparationNotBefore, ); @@ -132,7 +137,11 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { s.isStarted || s.preparationFrozen || s.doneStatus != ScheduleDoneStatus.notEnded || - s.occurrenceInstantUtc.isBefore(_now().toUtc()); + (ScheduleTimeResolver.resolve( + s, + nowUtc: _now(), + ).instantUtc?.isBefore(_now().toUtc()) ?? + true); Future> _excluded(String id) async => (await (db.select( db.recurringScheduleExclusions, )..where((t) => t.segmentId.equals(id))).get()).map((e) => e.slotKey).toSet(); @@ -184,6 +193,36 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { isStarted: false, ); + @override + ScheduleEntity candidateFor(RecurringSegment segment, RecurrenceSlot slot) => + _occurrence(segment, slot); + + @override + Future materializeCandidate( + RecurringSegment segment, + RecurrenceSlot slot, + ) async { + if (!segment.includes(slot) || + (await _excluded(segment.id)).contains(slot.key)) { + throw const RecurrenceSearchLimit(); + } + final candidate = _occurrence(segment, slot); + final existing = + await (db.select(db.schedules)..where( + (row) => + row.id.equals(candidate.id) | + (row.recurringSegmentId.equals(segment.id) & + row.recurringSlotKey.equals(slot.key)), + )) + .getSingleOrNull(); + if (existing == null) { + await db.scheduleDao.createSchedule(candidate.toScheduleWithPlaceRow()); + } + return (await db.scheduleDao.getScheduleById( + existing?.id ?? candidate.id, + )).toScheduleEntity(); + } + // The Gregorian weekday/month calendar repeats after 146097 days. Combine // that cycle with both interval periods; never claim a partial scan proves an // unbounded rule is conflict-free. Oversized scans fail explicitly. @@ -227,7 +266,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { ...exceptionDates, for (final s in stored) ...[ s.scheduleTime, - if (s.recurringSlotKey != null) DateTime.parse(s.recurringSlotKey!), + if (s.recurringSlotKey != null) _civilSlot(s.recurringSlotKey!), ], ]) { final after = date.add(const Duration(days: 2)); @@ -264,6 +303,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { DateTime? cutoff, }) async { _validatePreparation(preparation); + final evaluationNow = cutoff ?? _now(); // Validate/initialize bundled zones before computing the calendar proof. _engine.expand(rule, through: rule.start, limit: 1); final allSegments = await getSegments(); @@ -296,7 +336,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { rule, segments, [...relevant, ...detached, ...overrides.values], - exclusionsBySegment.values.expand((keys) => keys).map(DateTime.parse), + exclusionsBySegment.values.expand((keys) => keys).map(_civilSlot), ); final candidate = _engine.expand( rule, @@ -333,13 +373,21 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { final prep = value.preparationDefinitionId == null ? preparation.totalDuration : await definitionTime(value.preparationDefinitionId!); + final instant = ScheduleTimeResolver.resolve( + value, + nowUtc: evaluationNow, + ).instantUtc; + if (instant == null) { + throw const RecurrenceValidationException('일정 시간대와 발생 시각을 확인해 주세요.'); + } proposed.add(( slot: slot, interval: _BusyInterval( value, - value.occurrenceInstantUtc.subtract( + instant.subtract( prep + value.moveTime + (value.scheduleSpareTime ?? Duration.zero), ), + instant, ), )); } @@ -358,9 +406,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { for ( var j = i + 1; j < proposed.length && - !proposed[j].interval.start.isAfter( - a.interval.schedule.occurrenceInstantUtc, - ); + !proposed[j].interval.start.isAfter(a.interval.instant); j++ ) { final b = proposed[j]; @@ -388,7 +434,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { if (proposed.isNotEmpty && !persistent) { final firstInstant = proposed.first.interval.start; final lastInstant = proposed - .map((p) => p.interval.schedule.occurrenceInstantUtc) + .map((p) => p.interval.instant) .reduce((a, b) => a.isAfter(b) ? a : b); for (final segment in segments) { preparationTimes[segment.preparationId] = @@ -415,6 +461,11 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { .getPreparationUsersByUserId(localProfileId); final intervals = <_BusyInterval>[]; for (final value in byId.values) { + final instant = ScheduleTimeResolver.resolve( + value, + nowUtc: evaluationNow, + ).instantUtc; + if (instant == null) continue; Duration prep; if (value.preparationDefinitionId != null) { prep = await definitionTime(value.preparationDefinitionId!); @@ -428,11 +479,12 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { intervals.add( _BusyInterval( value, - value.occurrenceInstantUtc.subtract( + instant.subtract( prep + value.moveTime + (value.scheduleSpareTime ?? Duration.zero), ), + instant, ), ); } @@ -441,17 +493,13 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { for (final proposedValue in proposed) { final value = proposedValue.interval; while (index < intervals.length && - !intervals[index].schedule.occurrenceInstantUtc.isAfter( - value.start, - ) && - intervals[index].schedule.occurrenceInstantUtc != - value.schedule.occurrenceInstantUtc) { + !intervals[index].instant.isAfter(value.start) && + intervals[index].instant != value.instant) { index++; } for ( var j = index; - j < intervals.length && - !intervals[j].start.isAfter(value.schedule.occurrenceInstantUtc); + j < intervals.length && !intervals[j].start.isAfter(value.instant); j++ ) { final other = intervals[j]; @@ -476,11 +524,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { } // Keep the complete proof local; the form only needs previews and every // conflict key that can be explicitly excluded. - proposed.sort( - (a, b) => a.interval.schedule.occurrenceInstantUtc.compareTo( - b.interval.schedule.occurrenceInstantUtc, - ), - ); + proposed.sort((a, b) => a.interval.instant.compareTo(b.interval.instant)); final conflictKeys = conflicts .expand( (c) => [c.slot.key, if (c.otherSlotKey != null) c.otherSlotKey!], @@ -552,7 +596,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { Set excludedSlots = const {}, String? reviewedFirstSlotKey, }) async { - await db.transaction(() async { + await db.writeTransaction(() async { final existing = await (db.select( db.recurringScheduleSegments, )..where((t) => t.seriesId.equals(schedule.id))).get(); @@ -654,7 +698,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { DateTime through, { int? perSeriesLimit, }) async { - await db.transaction(() async { + await db.writeTransaction(() async { for (final segment in await getSegments()) { if (segment.beforeSlot != null && !segment.beforeSlot!.isAfter(RecurrenceRule.civilTime(from))) { @@ -677,6 +721,17 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { int? limit, Map overrides = const {}, }) async { + // Retained unknown-zone history is not a request to regenerate its past. + // Do this before engine lookup, including explicit historical queries. + if (!TimeZoneRules.contains(s.rule.timeZoneId) && + RecurrenceReferencePolicy.hasNoFutureGeneration( + rule: s.rule, + fromSlot: s.fromSlot, + beforeSlot: s.beforeSlot, + nowUtc: _now().toUtc(), + )) { + return; + } final slots = _expandSegment( s, through, @@ -686,9 +741,14 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { ); for (final slot in slots) { var schedule = _occurrence(s, slot); - final existing = await (db.select( - db.schedules, - )..where((t) => t.id.equals(schedule.id))).getSingleOrNull(); + final existing = + await (db.select(db.schedules)..where( + (t) => + t.id.equals(schedule.id) | + (t.recurringSegmentId.equals(s.id) & + t.recurringSlotKey.equals(slot.key)), + )) + .getSingleOrNull(); if (existing != null) continue; final old = overrides[_day(slot.civilTime)]; if (old != null) schedule = _mergeOverrides(schedule, old); @@ -701,6 +761,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { return base.copyWith( scheduleName: mask.contains('name') ? old.scheduleName : null, scheduleTime: mask.contains('time') ? old.scheduleTime : null, + timeZoneId: mask.contains('time') ? old.timeZoneId : null, occurrenceOffsetSeconds: mask.contains('time') ? old.occurrenceOffsetSeconds : null, @@ -722,7 +783,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { PreparationEntity preparation, { required bool preparationChanged, }) async { - await db.transaction(() async { + await db.writeTransaction(() async { final current = (await db.scheduleDao.getScheduleById( original.id, )).toScheduleEntity(); @@ -758,6 +819,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { .toSet(); if (current.scheduleName != updated.scheduleName) mask.add('name'); if (current.scheduleTime != updated.scheduleTime || + current.timeZoneId != updated.timeZoneId || current.occurrenceOffsetSeconds != updated.occurrenceOffsetSeconds) { mask.add('time'); } @@ -807,7 +869,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { original.id, )).toScheduleEntity(); final segment = await getSegment(current.recurringSegmentId!); - final anchor = DateTime.parse(current.recurringSlotKey!); + final anchor = _civilSlot(current.recurringSlotKey!); if (RecurrenceRule.civilDate( requested.start, ).isBefore(RecurrenceRule.civilDate(anchor))) { @@ -823,7 +885,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { (s) => segmentIds.contains(s.recurringSegmentId) && s.recurringSlotKey != null && - !DateTime.parse(s.recurringSlotKey!).isBefore(anchor), + !_civilSlot(s.recurringSlotKey!).isBefore(anchor), ) .toList(); final protected = stored.where(_protected).toList(); @@ -851,7 +913,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { for (final e in await (db.select( db.recurringScheduleExclusions, )..where((t) => t.segmentId.equals(s.id))).get()) { - final civil = DateTime.parse(e.slotKey); + final civil = _civilSlot(e.slotKey); if (!civil.isBefore(anchor) && s.includes( RecurrenceSlot( @@ -868,7 +930,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { // Protected slots consume their place just like explicit exclusions. If a // new rule no longer matches their day, reserve that part of the budget. for (final value in protected) { - exclusions[_day(DateTime.parse(value.recurringSlotKey!))] = + exclusions[_day(_civilSlot(value.recurringSlotKey!))] = value.recurringOrdinal!; } final target = remaining; @@ -911,9 +973,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { } final days = candidate.map((s) => _day(s.civilTime)).toSet(); final nextDetached = overrides - .where( - (s) => !days.contains(_day(DateTime.parse(s.recurringSlotKey!))), - ) + .where((s) => !days.contains(_day(_civilSlot(s.recurringSlotKey!)))) .toList(); final nextUnmatched = exclusions.keys .where((d) => !days.contains(d)) @@ -943,7 +1003,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { anchor, rule, detached, - {for (final s in overrides) _day(DateTime.parse(s.recurringSlotKey!)): s}, + {for (final s in overrides) _day(_civilSlot(s.recurringSlotKey!)): s}, exclusions.keys.toSet(), protected, candidate, @@ -999,7 +1059,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { String? reviewedFirstSlotKey, bool confirmDetached = false, }) async { - await db.transaction(() async { + await db.writeTransaction(() async { final plan = await _followingPlan( original, updated, @@ -1028,7 +1088,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { for (final s in rows) { if (!segmentIds.contains(s.recurringSegmentId) || s.recurringSlotKey == null || - DateTime.parse(s.recurringSlotKey!).isBefore(plan.anchor) || + _civilSlot(s.recurringSlotKey!).isBefore(plan.anchor) || _protected(s)) { continue; } @@ -1119,15 +1179,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { } Future _removeRow(String id) async { - await (db.update( - db.preparationSchedules, - )..where((t) => t.scheduleId.equals(id))).write( - const PreparationSchedulesCompanion(nextPreparationId: Value(null)), - ); - await (db.delete( - db.preparationSchedules, - )..where((t) => t.scheduleId.equals(id))).go(); - await (db.delete(db.schedules)..where((t) => t.id.equals(id))).go(); + await removeScheduleRow(db, id); } @override @@ -1135,7 +1187,7 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { ScheduleEntity occurrence, RecurringEditScope scope, ) async { - await db.transaction(() async { + await db.writeTransaction(() async { final current = (await db.scheduleDao.getScheduleById( occurrence.id, )).toScheduleEntity(); @@ -1148,22 +1200,22 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { current.recurringSlotKey!, current.recurringOrdinal!, ); - await _removeRow(current.id); + await removeScheduleOwnedContent(db, current.id); } else { final segment = await getSegment(current.recurringSegmentId!); final series = (await getSegments()) .where((s) => s.seriesId == segment.seriesId) .toList(); - final anchor = DateTime.parse(current.recurringSlotKey!); + final anchor = _civilSlot(current.recurringSlotKey!); await _closeFollowing(series, anchor); final ids = series.map((s) => s.id).toSet(); for (final r in await db.scheduleDao.getScheduleList()) { final s = r.toScheduleEntity(); if (ids.contains(s.recurringSegmentId) && s.recurringSlotKey != null && - !DateTime.parse(s.recurringSlotKey!).isBefore(anchor) && + !_civilSlot(s.recurringSlotKey!).isBefore(anchor) && !_protected(s)) { - await _removeRow(s.id); + await removeScheduleOwnedContent(db, s.id); } } } @@ -1173,14 +1225,14 @@ class RecurringScheduleRepositoryImpl implements RecurringScheduleRepository { } bool _overlaps(_BusyInterval a, _BusyInterval b) => - a.schedule.occurrenceInstantUtc == b.schedule.occurrenceInstantUtc || - (a.start.isBefore(b.schedule.occurrenceInstantUtc) && - b.start.isBefore(a.schedule.occurrenceInstantUtc)); + a.instant == b.instant || + (a.start.isBefore(b.instant) && b.start.isBefore(a.instant)); class _BusyInterval { - const _BusyInterval(this.schedule, this.start); + const _BusyInterval(this.schedule, this.start, this.instant); final ScheduleEntity schedule; final DateTime start; + final DateTime instant; } class _FollowingPlan { @@ -1205,3 +1257,5 @@ class _FollowingPlan { final List protected; final List candidates; } + +DateTime _civilSlot(String value) => CivilDateTime.parse(value).toUtcCarrier(); diff --git a/lib/data/repositories/recurring_time_correction_repository_impl.dart b/lib/data/repositories/recurring_time_correction_repository_impl.dart new file mode 100644 index 00000000..3ac7d444 --- /dev/null +++ b/lib/data/repositories/recurring_time_correction_repository_impl.dart @@ -0,0 +1,791 @@ +import 'dart:convert'; +import 'time_correction_conflict_reader.dart'; +import 'package:on_time_front/core/time/time_correction_conflicts.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; + +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart'; +import 'package:injectable/injectable.dart'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_recurrence_scan.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/recurring_time_correction_mapping.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/mappers/recurrence_codec.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_reference_policy.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/repositories/recurring_schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/recurring_time_correction_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_aggregate_repository.dart'; + +/// A narrow time/rule plan over the finite materialized source. This repository +/// is exclusively for the active store: authenticated backup candidates use a +/// separate staging adapter and must never call this implementation. +@Singleton(as: RecurringTimeCorrectionRepository) +class RecurringTimeCorrectionRepositoryImpl + implements RecurringTimeCorrectionRepository { + RecurringTimeCorrectionRepositoryImpl( + this.db, + this.aggregates, + this.recurring, { + @ignoreParam LocalDataOperationGate? gate, + @ignoreParam DateTime Function()? now, + }) : gate = gate ?? LocalDataOperationGate.shared, + now = now ?? DateTime.now; + final AppDatabase db; + final ScheduleAggregateRepository aggregates; + final RecurringScheduleRepository recurring; + final LocalDataOperationGate gate; + final DateTime Function() now; + static const _uuid = Uuid(); + + Never _reject(ScheduleSaveFailure failure) => + throw ScheduleSaveRejected(failure); + void _available() { + if (!gate.isAvailable) _reject(ScheduleSaveFailure.unavailable); + } + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => + gate.isAvailable && receipt.generation == gate.generation; + + String _hash(Object value) => + sha256.convert(utf8.encode(jsonEncode(value))).toString(); + + Future<_Source> _source( + ScheduleEditSnapshot snapshot, + BackupBudget budget, + ) async { + final segment = snapshot.segment; + if (segment == null || snapshot.baseline.rootId == null) { + _reject(ScheduleSaveFailure.invalid); + } + final segments = + await (db.select(db.recurringScheduleSegments) + ..where((t) => t.seriesId.equals(segment.seriesId)) + ..orderBy([(t) => OrderingTerm.asc(t.id)]) + ..limit(BackupLimits.records + 1)) + .get(); + if (segments.length > BackupLimits.records) { + BackupLimits.exceeded('records'); + } + final joined = await db.scheduleDao.getSeriesReferences( + segment.seriesId, + limit: BackupLimits.schedules + 1, + ); + if (joined.length > BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + final exclusions = + await (db.select(db.recurringScheduleExclusions).join([ + innerJoin( + db.recurringScheduleSegments, + db.recurringScheduleSegments.id.equalsExp( + db.recurringScheduleExclusions.segmentId, + ), + ), + ]) + ..where( + db.recurringScheduleSegments.seriesId.equals(segment.seriesId), + ) + ..orderBy([ + OrderingTerm.asc(db.recurringScheduleExclusions.segmentId), + OrderingTerm.asc(db.recurringScheduleExclusions.slotKey), + ]) + ..limit(BackupLimits.records + 1)) + .get(); + budget.visit(segments.length + joined.length + exclusions.length); + if (segments.length + joined.length + exclusions.length > + BackupLimits.records) { + BackupLimits.exceeded('records'); + } + final definitions = {}; + final steps = []; + final preparations = {}; + // Each unique definition is loaded once, and every retained step shares the + // same logical budget as the calendar scans. No unbounded whole-app query. + final ids = { + for (final row in segments) row.preparationId, + for (final row in joined) + if (row.schedule.preparationDefinitionId != null) + row.schedule.preparationDefinitionId!, + }.toList()..sort(); + for (final id in ids) { + budget.visit(); + final definition = await (db.select( + db.preparationDefinitions, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (definition == null) BackupLimits.invalid(); + definitions[id] = definition; + final owned = + await (db.select(db.preparationDefinitionSteps) + ..where((t) => t.definitionId.equals(id)) + ..orderBy([(t) => OrderingTerm.asc(t.position)]) + ..limit(BackupLimits.preparationSteps + 1)) + .get(); + if (owned.length > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + budget.visit(owned.length); + steps.addAll(owned); + if (segments.length + + joined.length + + exclusions.length + + definitions.length + + steps.length > + BackupLimits.records) { + BackupLimits.exceeded('records'); + } + preparations[id] = PreparationEntity( + preparationStepList: [ + for (var i = 0; i < owned.length; i++) + PreparationStepEntity( + id: owned[i].id, + preparationName: owned[i].name, + preparationTime: Duration(minutes: owned[i].minutes), + nextPreparationId: i + 1 < owned.length ? owned[i + 1].id : null, + ), + ], + ); + } + for (final row in segments) { + final definition = definitions[row.preparationId]!; + if (definition.scope != 'recurring' || + definition.ownerId != segment.seriesId) { + BackupLimits.invalid(); + } + } + final byId = {for (final row in segments) row.id: row}; + for (final row in joined) { + final schedule = row.schedule; + final definition = definitions[schedule.preparationDefinitionId]; + if (definition == null) BackupLimits.invalid(); + if (schedule.recurringOverrides.split(',').contains('preparation')) { + if (definition.scope != 'occurrence' || + definition.ownerId != schedule.id) { + BackupLimits.invalid(); + } + } else if (definition.id != + byId[schedule.recurringSegmentId]!.preparationId) { + BackupLimits.invalid(); + } + } + return _Source( + segments: [ + for (final row in segments) + RecurringSegment( + id: row.id, + seriesId: row.seriesId, + rule: RecurrenceCodec.ruleFromJson(jsonDecode(row.ruleJson)), + schedule: RecurrenceCodec.scheduleFromJson( + jsonDecode(row.scheduleJson), + ), + preparation: preparations[row.preparationId]!, + preparationId: row.preparationId, + fromSlot: CivilDateTime.parse(row.fromSlot).toUtcCarrier(), + beforeSlot: row.beforeSlot == null + ? null + : CivilDateTime.parse(row.beforeSlot!).toUtcCarrier(), + createdAt: row.createdAt, + preparationNotBefore: row.preparationNotBefore, + ), + ], + rows: joined.map((row) => row.toScheduleEntity()).toList(), + exclusions: [ + for (final row in exclusions) + TimeCorrectionExclusion( + row.readTable(db.recurringScheduleExclusions).segmentId, + row.readTable(db.recurringScheduleExclusions).slotKey, + row.readTable(db.recurringScheduleExclusions).ordinal, + ), + ], + preparations: preparations, + occurrenceOwners: { + for (final value in definitions.values) + if (value.scope == 'occurrence') value.id: value.ownerId, + }, + digest: _hash([ + segments.map((e) => e.toJson()).toList(), + joined.map((e) => [e.schedule.toJson(), e.place.toJson()]).toList(), + exclusions + .map((e) => e.readTable(db.recurringScheduleExclusions).toJson()) + .toList(), + definitions.values.map((e) => e.toJson()).toList(), + steps.map((e) => e.toJson()).toList(), + ]), + ); + } + + Future _authority( + ScheduleEditSnapshot snapshot, + RecurringTimeCorrectionRequest request, + DateTime at, + BackupBudget budget, + ) async { + final previous = request.anchorReview; + if (snapshot.baseline != previous.snapshot.baseline || + previous.ruleIdentity != TimeZoneRules.loadedIdentity || + at.isBefore(previous.validationNowUtc)) { + _reject(ScheduleSaveFailure.conflict); + } + final row = snapshot.schedule; + final resolution = ScheduleTimeResolver.resolve(row, nowUtc: at); + if (resolution != previous.resolution) { + _reject(ScheduleSaveFailure.conflict); + } + if (snapshot.segment == null || + row.retainedRecurringReference || + RecurrenceReferencePolicy.hasProtectedFacts(row) || + row.startedAt != null || + row.finishedAt != null || + row.scoreContributionRecorded || + resolution.isHistorical || + RecurrenceReferencePolicy.isProvablyPast(row, at)) { + _reject(ScheduleSaveFailure.protected); + } + switch (resolution.status) { + case ScheduleTimeResolutionStatus.unknownZone: + case ScheduleTimeResolutionStatus.nonexistent: + case ScheduleTimeResolutionStatus.ambiguous: + case ScheduleTimeResolutionStatus.changed: + return; + case ScheduleTimeResolutionStatus.historicalUncertain: + case ScheduleTimeResolutionStatus.invalid: + _reject(ScheduleSaveFailure.protected); + case ScheduleTimeResolutionStatus.resolved: + // A structurally valid stored ordinal can become stale while the + // target's own offset remains valid. Verify that condition ourselves. + final source = snapshot.segment!; + BackupRuleCandidate? observed; + final anchor = CivilDateTime.parse( + row.recurringSlotKey!, + ).toUtcCarrier(); + await for (final candidate in scanBackupRule( + rule: source.rule, + through: anchor, + budget: budget, + leadTime: source.leadTime, + preparationNotBefore: source.preparationNotBefore, + )) { + if (candidate.civil == anchor) observed = candidate; + } + final staleOrdinal = + observed == null || + !observed.eligible || + observed.currentOrdinal != row.recurringOrdinal; + final lead = + snapshot.preparation.totalDuration + + row.moveTime + + (row.scheduleSpareTime ?? Duration.zero); + if (!staleOrdinal && + !resolution.instantUtc!.subtract(lead).isAfter(at)) { + _reject(ScheduleSaveFailure.protected); + } + } + } + + @override + Future review( + RecurringTimeCorrectionRequest request, + ) => db.transaction(() async { + _available(); + final at = now().toUtc(); + final budget = BackupBudget(); + final snapshot = await aggregates.readForEdit( + request.anchorReview.snapshot.schedule.id, + ); + await _authority(snapshot, request, at, budget); + final source = await _source(snapshot, budget); + var mapping = await RecurringTimeCorrectionMapper.build( + anchor: snapshot.schedule, + segments: source.segments, + schedules: source.rows, + exclusions: source.exclusions, + occurrenceDefinitionOwners: source.occurrenceOwners, + requested: request.rule, + endExplicitlyChosen: request.endExplicitlyChosen, + nowUtc: at, + budget: budget, + ); + final unresolved = {}; + for (final row in mapping.rows) { + if (row.original.recurringOverrides.split(',').contains('time')) { + final time = ScheduleTimeResolver.resolve(row.original, nowUtc: at); + if (time.instantUtc == null) unresolved[row.original.id] = time.status; + } + } + TimeCorrectionConflictProof? proof; + if (unresolved.isEmpty) { + proof = await _proof(snapshot, source, mapping, at, budget); + final excluded = request.excludedConflictSlots; + if (excluded.isNotEmpty) { + final eligible = { + for (final conflict in proof.conflicts.where( + (e) => !e.persistent, + )) ...[ + conflict.slot.key, + if (conflict.otherSlotKey != null) conflict.otherSlotKey!, + ], + }; + if (!eligible.containsAll(excluded)) { + _reject(ScheduleSaveFailure.invalid); + } + final slots = proof.proposedSlots + .where((e) => excluded.contains(e.key)) + .toList(); + if (slots.length != excluded.length) { + _reject(ScheduleSaveFailure.invalid); + } + mapping = RecurringTimeCorrectionMapping( + rule: mapping.rule, + automaticallyRetainedCount: mapping.automaticallyRetainedCount, + rows: [ + for (final row in mapping.rows) + row.slot != null && excluded.contains(row.slot!.key) + ? TimeCorrectionRowMapping(row.original, null) + : row, + ], + protectedRows: mapping.protectedRows, + exclusions: mapping.exclusions, + protectedSlots: mapping.protectedSlots, + workUnits: budget.work, + firstSlot: mapping.firstSlot, + conflictExclusions: slots, + ); + // A materialized excluded row remains a standalone appointment. Re-read + // it as an ordinary collision candidate; exclusion never deletes it. + proof = await _proof(snapshot, source, mapping, at, budget); + } + } else if (request.excludedConflictSlots.isNotEmpty) { + _reject(ScheduleSaveFailure.invalid); + } + final finalNow = now().toUtc(); + if (finalNow.isBefore(at)) _reject(ScheduleSaveFailure.conflict); + await _authority(snapshot, request, finalNow, budget); + _available(); + return RecurringTimeCorrectionReview( + request: request, + mapping: mapping, + sourceDigest: source.digest, + unresolvedOverrides: unresolved, + conflictProof: proof, + ); + }); + + Future _proof( + ScheduleEditSnapshot snapshot, + _Source source, + RecurringTimeCorrectionMapping mapping, + DateTime at, + BackupBudget budget, + ) async { + final segment = snapshot.segment!; + final world = await readTimeCorrectionConflictWorld( + db: db, + nowUtc: at, + budget: budget, + ignoredIds: { + for (final row in mapping.rows) + if (!row.detached) row.original.id, + }, + closingSeriesAt: { + segment.seriesId: CivilDateTime.parse( + snapshot.schedule.recurringSlotKey!, + ).toUtcCarrier(), + }, + ); + final overrides = {}; + for (final row in mapping.rows.where((e) => !e.detached)) { + final original = row.original; + overrides[row.slot!.key] = + original.recurringOverrides.split(',').contains('time') + ? original + : original.copyWith( + scheduleTime: row.slot!.civilTime, + timeZoneId: mapping.rule.timeZoneId, + occurrenceOffsetSeconds: row.slot!.offsetSeconds, + ); + } + final proof = await TimeCorrectionConflictScanner.scan( + rule: mapping.rule, + base: segment.schedule, + basePreparation: segment.preparation.totalDuration, + proposedOverrides: overrides, + proposedPreparationById: { + for (final value in overrides.values) + value.id: + source.preparations[value.preparationDefinitionId]!.totalDuration, + }, + excludedSlots: { + for (final slot in mapping.protectedSlots) slot.key, + for (final entry in mapping.exclusions) + if (entry.slot != null) entry.slot!.key, + for (final slot in mapping.conflictExclusions) slot.key, + }, + storedOthers: world.rows, + otherSegments: world.segments, + otherExcludedSlots: world.exclusions, + nowUtc: at, + budget: budget, + ); + final first = proof.earliestPreparationUtc!; + final last = proof.latestTargetUtc!; + return TimeCorrectionConflictProof( + conflicts: proof.conflicts, + through: proof.through, + workUnits: budget.work, + proposedSlots: proof.proposedSlots, + earliestPreparationUtc: first, + latestTargetUtc: last, + possibleOverlaps: [ + for (final impact in [ + ...world.possibleOverlaps, + ...proof.possibleOverlaps, + ]) + if (impact.mayOverlap(first, last)) impact, + ], + ); + } + + String _digest(RecurringTimeCorrectionCommand command) => _hash([ + 'recurring-time-correction-v1', + command.review.request.anchorReview.snapshot.schedule.id, + command.review.request.anchorReview.snapshot.baseline.props, + command.review.request.anchorReview.ruleIdentity, + command.review.request.anchorReview.validationNowUtc.toIso8601String(), + command.review.sourceDigest, + RecurrenceCodec.ruleToJson(command.review.request.rule), + command.review.request.endExplicitlyChosen, + command.review.request.excludedConflictSlots.toList()..sort(), + command.acknowledgedUncertainIds.toList()..sort(), + command.confirmedDetachedIds.toList()..sort(), + command.confirmedUnmatchedExclusions.toList()..sort(), + ]); + + @override + Future confirm( + RecurringTimeCorrectionCommand command, + ) => db.transaction(() async { + _available(); + final request = command.review.request; + final expected = request.anchorReview.snapshot.baseline; + final id = request.anchorReview.snapshot.schedule.id; + final digest = _digest(command); + final profile = await (db.select( + db.users, + )..where((t) => t.id.equals(localProfileId))).getSingle(); + final row = await (db.select( + db.schedules, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (command.mutationId.isEmpty || + expected.incarnation == null || + expected.rootId == null) { + _reject(ScheduleSaveFailure.invalid); + } + if (profile.storeIncarnation != expected.store || + gate.generation != expected.generation || + row == null || + row.aggregateIncarnation != expected.incarnation) { + _reject(ScheduleSaveFailure.conflict); + } + final root = await (db.select( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(expected.rootId!))).getSingleOrNull(); + if (root == null) _reject(ScheduleSaveFailure.conflict); + if (row.lastMutationId == command.mutationId && + row.lastMutationDigest == digest && + row.lastMutationVersion == row.aggregateVersion && + root.lastMutationId == command.mutationId && + root.lastMutationDigest == digest && + root.lastMutationVersion == root.aggregateVersion) { + return ScheduleSaveReceipt( + scheduleId: id, + mutationId: command.mutationId, + generation: gate.generation, + changed: false, + ); + } + if (row.lastMutationId == command.mutationId) { + _reject(ScheduleSaveFailure.conflict); + } + if (root.lastMutationId == command.mutationId) { + _reject(ScheduleSaveFailure.conflict); + } + // Recompute from actual rows. Client-supplied mapping objects and + // diagnostic flags are never used as write authority. + final fresh = await review(request); + if (fresh.sourceDigest != command.review.sourceDigest || + fresh.unresolvedOverrides.isNotEmpty) { + _reject(ScheduleSaveFailure.conflict); + } + final proof = fresh.conflictProof; + if (proof == null) _reject(ScheduleSaveFailure.invalid); + if (proof.conflicts.isNotEmpty) throw ScheduleTimeCorrectionConflict(proof); + if (!const SetEquality().equals( + proof.possibleOverlaps.map((e) => e.id).toSet(), + command.acknowledgedUncertainIds, + )) { + _reject(ScheduleSaveFailure.invalid); + } + final mapping = fresh.mapping; + final detached = mapping.rows + .where((e) => e.detached) + .map((e) => e.original.id) + .toSet(); + final unmatched = mapping.exclusions + .where((e) => e.slot == null) + .map((e) => '${e.original.segmentId}\n${e.original.slot}') + .toSet(); + if (!const SetEquality().equals( + detached, + command.confirmedDetachedIds, + ) || + !const SetEquality().equals( + unmatched, + command.confirmedUnmatchedExclusions, + )) { + _reject(ScheduleSaveFailure.invalid); + } + final snapshot = await aggregates.readForEdit(id); + final source = snapshot.segment!; + final first = mapping.firstSlot; + if (first == null) _reject(ScheduleSaveFailure.invalid); + final at = now().toUtc(); + final desired = {}; + for (final change in mapping.rows) { + final original = change.original; + final slot = change.slot; + desired[original.id] = + slot == null || + original.recurringOverrides.split(',').contains('time') + ? original + : original.copyWith( + scheduleTime: slot.civilTime, + timeZoneId: mapping.rule.timeZoneId, + occurrenceOffsetSeconds: slot.offsetSeconds, + ); + } + final budget = BackupBudget()..visit(proof.workUnits); + final preparations = {}; + Future validateFuture(DateTime finalNow) async { + if (!first.instantUtc.subtract(source.leadTime).isAfter(finalNow)) { + _reject(ScheduleSaveFailure.invalid); + } + for (final change in mapping.rows.where((e) => !e.detached)) { + final value = desired[change.original.id]!; + final resolution = ScheduleTimeResolver.resolve( + value, + nowUtc: finalNow, + ); + final instant = resolution.instantUtc; + final definition = value.preparationDefinitionId!; + if (!preparations.containsKey(definition)) { + final loaded = await recurring.getPreparation(definition); + budget.visit(loaded.preparationStepList.length + 1); + preparations[definition] = loaded; + } + budget.visit(); + final preparation = preparations[definition]!; + final lead = + preparation.totalDuration + + value.moveTime + + (value.scheduleSpareTime ?? Duration.zero); + if (resolution.status != ScheduleTimeResolutionStatus.resolved || + resolution.isHistorical || + instant == null || + !instant.isAfter(finalNow) || + !instant.subtract(lead).isAfter(finalNow)) { + _reject(ScheduleSaveFailure.invalid); + } + } + } + + await validateFuture(at); + final newId = _uuid.v7(); + final anchor = CivilDateTime.parse( + snapshot.schedule.recurringSlotKey!, + ).toUtcCarrier(); + final segments = await (db.select( + db.recurringScheduleSegments, + )..where((t) => t.seriesId.equals(source.seriesId))).get(); + for (final segment in segments) { + final before = segment.beforeSlot == null + ? null + : CivilDateTime.parse(segment.beforeSlot!).toUtcCarrier(); + if (before != null && !before.isAfter(anchor)) continue; + final from = CivilDateTime.parse(segment.fromSlot).toUtcCarrier(); + final boundary = from.isAfter(anchor) ? from : anchor; + await (db.update( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(segment.id))).write( + RecurringScheduleSegmentsCompanion( + beforeSlot: Value( + CivilDateTime.fromFields(boundary).toCivilIso8601String(), + ), + ), + ); + } + // The base keeps its own non-time values; occurrence overrides retain + // theirs. A time correction does not clone or re-own preparations. + final base = source.schedule.copyWith( + scheduleTime: mapping.rule.start, + timeZoneId: mapping.rule.timeZoneId, + occurrenceOffsetSeconds: first.offsetSeconds, + ); + await db + .into(db.recurringScheduleSegments) + .insert( + RecurringScheduleSegmentsCompanion.insert( + id: newId, + seriesId: source.seriesId, + rootSegmentId: Value(expected.rootId), + ruleJson: jsonEncode(RecurrenceCodec.ruleToJson(mapping.rule)), + scheduleJson: jsonEncode(RecurrenceCodec.scheduleToJson(base)), + preparationId: source.preparationId, + fromSlot: CivilDateTime.fromFields( + mapping.rule.start, + ).toCivilIso8601String(), + createdAt: at, + preparationNotBefore: Value(at), + ), + ); + for (final change in mapping.rows) { + final value = desired[change.original.id]!; + final slot = change.slot; + final overrides = value.recurringOverrides + .split(',') + .where((e) => e.isNotEmpty) + .toSet(); + if (value.scheduleName != base.scheduleName) overrides.add('name'); + if (value.place != base.place) overrides.add('place'); + if (value.moveTime != base.moveTime) overrides.add('move'); + if (value.scheduleSpareTime != base.scheduleSpareTime) { + overrides.add('spare'); + } + if (value.scheduleNote != base.scheduleNote) overrides.add('note'); + await (db.update( + db.schedules, + )..where((t) => t.id.equals(value.id))).write( + SchedulesCompanion( + recurringSegmentId: Value(slot == null ? null : newId), + recurringSlotKey: Value(slot?.key), + recurringOrdinal: Value(slot?.ordinal), + recurringOverrides: Value( + slot == null ? '' : (overrides.toList()..sort()).join(','), + ), + scheduleTime: Value(value.scheduleTime), + timeZoneId: Value(value.timeZoneId), + occurrenceOffsetSeconds: Value(value.occurrenceOffsetSeconds), + ), + ); + } + final exclusions = { + ...mapping.protectedSlots, + ...mapping.conflictExclusions, + for (final exclusion in mapping.exclusions) + if (exclusion.slot != null) exclusion.slot!, + }; + for (final slot in exclusions) { + await db + .into(db.recurringScheduleExclusions) + .insert( + RecurringScheduleExclusionsCompanion.insert( + segmentId: newId, + slotKey: slot.key, + ordinal: slot.ordinal, + ), + ); + } + await db.userDao.markDurableDataChanged(localProfileId); + final saved = await (db.select( + db.schedules, + )..where((t) => t.id.equals(id))).getSingle(); + await (db.update(db.schedules)..where((t) => t.id.equals(id))).write( + SchedulesCompanion( + lastMutationId: Value(command.mutationId), + lastMutationDigest: Value(digest), + lastMutationVersion: Value(saved.aggregateVersion), + ), + ); + final savedRoot = await (db.select( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(expected.rootId!))).getSingle(); + await (db.update( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(expected.rootId!))).write( + RecurringScheduleSegmentsCompanion( + lastMutationId: Value(command.mutationId), + lastMutationDigest: Value(digest), + lastMutationVersion: Value(savedRoot.aggregateVersion), + ), + ); + final finalNow = now().toUtc(); + if (finalNow.isBefore(at) || + finalNow.isBefore(request.anchorReview.validationNowUtc) || + request.anchorReview.ruleIdentity != TimeZoneRules.loadedIdentity || + gate.generation != expected.generation) { + _reject(ScheduleSaveFailure.conflict); + } + await _authority(snapshot, request, finalNow, budget); + await validateFuture(finalNow); + final claimNow = now().toUtc(); + if (claimNow.isBefore(finalNow) || + TimeZoneRules.loadedIdentity != request.anchorReview.ruleIdentity) { + _reject(ScheduleSaveFailure.conflict); + } + // These cached values need no await; the actual final clock reading + // cannot reuse a selection after its preparation boundary has passed. + if (!first.instantUtc.subtract(source.leadTime).isAfter(claimNow)) { + _reject(ScheduleSaveFailure.invalid); + } + for (final change in mapping.rows.where((e) => !e.detached)) { + final value = desired[change.original.id]!; + final resolution = ScheduleTimeResolver.resolve(value, nowUtc: claimNow); + final instant = resolution.instantUtc; + final lead = + preparations[value.preparationDefinitionId!]!.totalDuration + + value.moveTime + + (value.scheduleSpareTime ?? Duration.zero); + if (resolution.isHistorical || + instant == null || + !instant.subtract(lead).isAfter(claimNow)) { + _reject(ScheduleSaveFailure.invalid); + } + } + _available(); + return ScheduleSaveReceipt( + scheduleId: id, + mutationId: command.mutationId, + generation: gate.generation, + changed: true, + ); + }); +} + +final class _Source { + const _Source({ + required this.segments, + required this.rows, + required this.exclusions, + required this.occurrenceOwners, + required this.digest, + required this.preparations, + }); + final List segments; + final List rows; + final List exclusions; + final Map occurrenceOwners; + final String digest; + final Map preparations; +} diff --git a/lib/data/repositories/schedule_aggregate_repository_impl.dart b/lib/data/repositories/schedule_aggregate_repository_impl.dart index 4de689b9..1bdcf263 100644 --- a/lib/data/repositories/schedule_aggregate_repository_impl.dart +++ b/lib/data/repositories/schedule_aggregate_repository_impl.dart @@ -1,3 +1,7 @@ +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/data/daos/schedule_owned_content_cleanup.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart'; @@ -108,6 +112,154 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { : await recurring.getSegment(schedule.recurringSegmentId!), ); }); + @override + Future readForDeletion( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) => db.transaction(() async { + final generation = gate.captureWrite(); + final snapshot = await readForEdit(id); + final targets = await _deletionTargets(snapshot, scope); + gate.checkWrite(generation); + return ScheduleDeletionIntent( + intentId: uuid.v7(), + snapshot: snapshot, + scope: scope, + targets: targets, + ); + }); + + Future> _deletionTargets( + ScheduleEditSnapshot snapshot, + RecurringEditScope scope, + ) async { + final selected = snapshot.schedule; + if (selected.isStarted) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.protected); + } + final selectedTarget = ScheduleDeletionTarget( + id: selected.id, + incarnation: snapshot.baseline.incarnation!, + version: snapshot.baseline.version!, + ); + if (scope == RecurringEditScope.occurrence) return [selectedTarget]; + final evaluationNow = now().toUtc(); + bool protected(ScheduleEntity value) => + value.isStarted || + value.preparationFrozen || + value.doneStatus != ScheduleDoneStatus.notEnded || + !(ScheduleTimeResolver.resolve( + value, + nowUtc: evaluationNow, + ).instantUtc?.isAfter(evaluationNow) ?? + false); + if (!selected.isRecurring || protected(selected)) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.protected); + } + final segment = snapshot.segment!; + final seriesIds = (await recurring.getSegments()) + .where((value) => value.seriesId == segment.seriesId) + .map((value) => value.id) + .toSet(); + final anchor = _civilSlot(selected.recurringSlotKey!); + final targets = []; + for (final row in await db.scheduleDao.getScheduleList()) { + final value = row.toScheduleEntity(); + if (seriesIds.contains(value.recurringSegmentId) && + value.recurringSlotKey != null && + !_civilSlot(value.recurringSlotKey!).isBefore(anchor) && + !protected(value)) { + targets.add( + ScheduleDeletionTarget( + id: value.id, + incarnation: row.schedule.aggregateIncarnation!, + version: row.schedule.aggregateVersion!, + ), + ); + } + } + return targets..sort((a, b) => a.id.compareTo(b.id)); + } + + @override + Future delete( + ScheduleDeletionIntent intent, + ) => db.writeTransaction(() async { + final baseline = intent.snapshot.baseline; + final profile = await _profile(); + if (profile.storeIncarnation != baseline.store || + gate.generation != baseline.generation) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + final id = intent.snapshot.schedule.id; + final row = await (db.select( + db.schedules, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (row == null) { + return ScheduleDeletionCommit( + scheduleId: id, + store: baseline.store, + generation: baseline.generation, + removedIds: const {}, + changed: false, + alreadyAbsent: true, + ); + } + final latest = await readForEdit(id); + if (latest.baseline.incarnation != baseline.incarnation || + latest.baseline.version != baseline.version || + latest.baseline.rootIncarnation != baseline.rootIncarnation || + (intent.scope == RecurringEditScope.following && + latest.baseline.rootVersion != baseline.rootVersion)) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + final targets = await _deletionTargets(latest, intent.scope); + if (targets.length != intent.targets.length || + List.generate( + targets.length, + (i) => targets[i] != intent.targets[i], + ).any((different) => different)) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + if (latest.schedule.isRecurring) { + // This nested transaction owns the single durable revision change. + await recurring.delete(latest.schedule, intent.scope); + } else { + await removeScheduleOwnedContent(db, id); + await db.userDao.markDurableDataChanged(localProfileId); + } + return ScheduleDeletionCommit( + scheduleId: id, + store: baseline.store, + generation: baseline.generation, + removedIds: targets.map((value) => value.id).toSet(), + changed: true, + alreadyAbsent: false, + ); + }, gate: gate); + + @override + Future isDeletionCurrent(ScheduleDeletionCommit commit) => + db.transaction(() async { + if (gate.isInvalidated || + gate.isReplacingData || + gate.isRecoveryPending || + gate.generation != commit.generation) { + return false; + } + final profile = await _profile(); + if (profile.storeIncarnation != commit.store) return false; + final ids = {...commit.removedIds, commit.scheduleId}; + final rows = await (db.select( + db.schedules, + )..where((t) => t.id.isIn(ids))).get(); + return rows.isEmpty && + gate.generation == commit.generation && + !gate.isInvalidated && + !gate.isReplacingData && + !gate.isRecoveryPending; + }); + Never _reject(ScheduleSaveFailure value) => throw ScheduleSaveRejected(value); List _steps(PreparationEntity p) => p.ordered.preparationStepList .map((s) => [s.preparationName, s.preparationTime.inMinutes]) @@ -224,7 +376,7 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { if (baseline == null || mutation == null || mutation.isEmpty) { _reject(ScheduleSaveFailure.invalid); } - if (gate.isInvalidated || gate.isReplacingData) { + if (gate.isInvalidated || gate.isReplacingData || gate.isRecoveryPending) { _reject(ScheduleSaveFailure.unavailable); } final profile = await _profile(); @@ -302,6 +454,17 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { changed: false, ); } + if (v.validateTimeReview != null && + profile.dataRevision != baseline.revision) { + _reject(ScheduleSaveFailure.conflict); + } + v.validateTimeReview?.call(); + if ((row?.lastMutationId == mutation && + row?.lastMutationDigest != digest) || + (root?.lastMutationId == mutation && + root?.lastMutationDigest != digest)) { + _reject(ScheduleSaveFailure.conflict); + } if (editing) { if (row == null || row.aggregateIncarnation != baseline.incarnation || @@ -318,10 +481,15 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { prep.totalDuration + current.moveTime + (current.scheduleSpareTime ?? Duration.zero); - if (current.isStarted || + final currentInstant = ScheduleTimeResolver.resolve( + current, + nowUtc: now(), + ).instantUtc; + if (currentInstant == null || + current.isStarted || current.preparationFrozen || current.doneStatus != ScheduleDoneStatus.notEnded || - !current.occurrenceInstantUtc.subtract(lead).isAfter(now().toUtc())) { + !currentInstant.subtract(lead).isAfter(now().toUtc())) { _reject(ScheduleSaveFailure.protected); } } else if (row != null || @@ -431,6 +599,7 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { ), ); } + v.validateTimeReview?.call(); return ScheduleSaveReceipt( scheduleId: v.schedule.id, mutationId: mutation, @@ -456,7 +625,7 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { parts.any( (p) => p.id != segment.id && - DateTime.parse(p.fromSlot).compareTo(segment.fromSlot) > 0, + _civilSlot(p.fromSlot).compareTo(segment.fromSlot) > 0, )) { return false; } @@ -469,21 +638,8 @@ class ScheduleAggregateRepositoryImpl implements ScheduleAggregateRepository { jsonEncode(RecurrenceCodec.ruleToJson(v.recurrenceRule!)); } - Future _collectUnusedOwnedDefinition(String candidate) async { - final unused = await db - .customSelect( - "SELECT id FROM preparation_definitions d WHERE id=? AND scope IN ('recurring','occurrence') AND NOT EXISTS(SELECT 1 FROM schedules s WHERE s.preparation_definition_id=d.id) AND NOT EXISTS(SELECT 1 FROM recurring_schedule_segments r WHERE r.preparation_id=d.id)", - variables: [Variable(candidate)], - ) - .get(); - if (unused.isEmpty) return; - await (db.delete( - db.preparationDefinitionSteps, - )..where((t) => t.definitionId.equals(candidate))).go(); - await (db.delete( - db.preparationDefinitions, - )..where((t) => t.id.equals(candidate))).go(); - } + Future _collectUnusedOwnedDefinition(String candidate) => + collectUnusedScheduleDefinition(db, candidate); Future _saveOrdinary( ScheduleFormSubmission v, { @@ -630,3 +786,5 @@ Future readSchedulePreparation( if (custom.preparationStepList.isNotEmpty) return custom; return db.preparationUserDao.getPreparationUsersByUserId(localProfileId); } + +DateTime _civilSlot(String value) => CivilDateTime.parse(value).toUtcCarrier(); diff --git a/lib/data/repositories/schedule_repository_impl.dart b/lib/data/repositories/schedule_repository_impl.dart index 9a282ec2..999e42d6 100644 --- a/lib/data/repositories/schedule_repository_impl.dart +++ b/lib/data/repositories/schedule_repository_impl.dart @@ -1,3 +1,12 @@ +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/core/startup/subscription_cleanup.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; import 'dart:async'; @@ -17,24 +26,31 @@ import 'package:rxdart/subjects.dart'; import 'package:on_time_front/domain/repositories/recurring_schedule_repository.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; -@Singleton(as: ScheduleRepository) +Future disposeScheduleRepository(ScheduleRepository resource) => + StartupDependencyScope.release( + resource, + (resource as ScheduleRepositoryImpl).dispose, + ); + +@Singleton(as: ScheduleRepository, dispose: disposeScheduleRepository) class ScheduleRepositoryImpl implements ScheduleRepository { ScheduleRepositoryImpl({ required AppDatabase database, required TimedPreparationRepository timedPreparationRepository, RecurringScheduleRepository? recurringScheduleRepository, - }) : _recurring = recurringScheduleRepository, + @ignoreParam DateTime Function()? now, + }) : _now = now ?? DateTime.now, + _recurring = recurringScheduleRepository, _database = database, _scheduleDao = database.scheduleDao, _userDao = database.userDao, _timedPreparationRepository = timedPreparationRepository { - _subscription = _scheduleDao.watchScheduleList().listen( - (rows) => _scheduleStreamController.add( - rows.map((row) => row.toScheduleEntity()).toSet(), - ), - ); + StartupDependencyScope.own(this, dispose); + LocalDataOperationGate.shared.addListener(_observeCurrentGeneration); + _observeCurrentGeneration(); } + final DateTime Function() _now; final RecurringScheduleRepository? _recurring; final AppDatabase _database; final ScheduleDao _scheduleDao; @@ -43,7 +59,56 @@ class ScheduleRepositoryImpl implements ScheduleRepository { final _scheduleStreamController = BehaviorSubject>.seeded( const {}, ); - late final StreamSubscription> _subscription; + StreamSubscription>? _subscription; + int _watchGeneration = -1; + Object? _subscriptionOwner; + final _retiredWatches = SubscriptionCleanup(); + bool _disposed = false; + Future? _subjectClose; + Future? _disposeFlight; + void _observeCurrentGeneration() { + if (_disposed) return; + final gate = LocalDataOperationGate.shared; + if (_watchGeneration != gate.generation || + gate.isRecoveryPending || + gate.isInvalidated) { + _subscriptionOwner = null; + _retiredWatches.retire(_subscription); + _subscription = null; + _scheduleStreamController.add(const {}); + } + if (_subscription != null || + gate.isReplacingData || + gate.isRecoveryPending || + gate.isInvalidated) { + return; + } + final generation = gate.generation; + final owner = Object(); + _watchGeneration = generation; + _subscriptionOwner = owner; + _subscription = _scheduleDao.watchScheduleList().listen( + (rows) { + if (identical(owner, _subscriptionOwner) && + generation == gate.generation && + !gate.isReplacingData && + !gate.isRecoveryPending && + !gate.isInvalidated) { + _scheduleStreamController.add( + rows.map((row) => row.toScheduleEntity()).toSet(), + ); + } + }, + onError: (Object error, StackTrace stack) { + if (identical(owner, _subscriptionOwner) && + generation == gate.generation && + !gate.isInvalidated && + !gate.isReplacingData) { + _scheduleStreamController.addError(error, stack); + } + }, + ); + } @override Stream> get scheduleStream => @@ -60,8 +125,16 @@ class ScheduleRepositoryImpl implements ScheduleRepository { final result = schedules .where( (schedule) => - !schedule.scheduleTime.isBefore(startDate) && - schedule.scheduleTime.isBefore(endDate), + !CivilDateTime.fromFields( + schedule.scheduleTime, + ).toUtcCarrier().isBefore( + CivilDateTime.fromFields(startDate).toUtcCarrier(), + ) && + CivilDateTime.fromFields( + schedule.scheduleTime, + ).toUtcCarrier().isBefore( + CivilDateTime.fromFields(endDate).toUtcCarrier(), + ), ) .toList(); result.sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); @@ -72,44 +145,139 @@ class ScheduleRepositoryImpl implements ScheduleRepository { @override Future createSchedule(ScheduleEntity schedule) async { - await _scheduleDao.createSchedule(schedule.toScheduleWithPlaceRow()); - await _userDao.markDurableDataChanged(localProfileId); + await _database.writeTransaction(() async { + await _scheduleDao.createSchedule(schedule.toScheduleWithPlaceRow()); + await _userDao.markDurableDataChanged(localProfileId); + }); } @override Future deleteSchedule(ScheduleEntity schedule) async { - if (schedule.isRecurring && _recurring != null) { - await _recurring.delete(schedule, RecurringEditScope.occurrence); - } else { - await _scheduleDao.deleteSchedule(schedule.toScheduleRow()); + final generation = LocalDataOperationGate.shared.captureWrite(); + await _database.writeTransaction(() async { + final current = await (_database.select( + _database.schedules, + )..where((row) => row.id.equals(schedule.id))).getSingleOrNull(); + if (current == null) return; + if (current.isStarted) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.protected); + } + if (current.recurringSegmentId != null && _recurring != null) { + final latest = (await _scheduleDao.getScheduleById( + schedule.id, + )).toScheduleEntity(); + await _recurring.delete(latest, RecurringEditScope.occurrence); + } else { + await _scheduleDao.deleteSchedule(current); + await _userDao.markDurableDataChanged(localProfileId); + } + }); + if (generation == LocalDataOperationGate.shared.generation) { + await _clearTimedPreparation(schedule.id); } - await _clearTimedPreparation(schedule.id); - await _userDao.markDurableDataChanged(localProfileId); } @override Future startSchedule( String scheduleId, { DateTime? startedAt, - }) => _database.transaction(() async { - final existing = (await _scheduleDao.getScheduleById(scheduleId)).schedule; + bool Function()? isCurrent, + String? expectedFingerprint, + }) => _database.writeTransaction(() async { + void checkIntent() { + if (!(isCurrent?.call() ?? true)) throw ScheduleStartRejected(scheduleId); + } + + checkIntent(); + final joined = await _scheduleDao.getScheduleById(scheduleId); + checkIntent(); + final existing = joined.schedule; + if (expectedFingerprint != null) { + final actual = joined.toScheduleEntity(); + if (actual.preparationDefinitionId != null) { + final definition = + await (_database.select(_database.preparationDefinitions)..where( + (row) => row.id.equals(actual.preparationDefinitionId!), + )) + .getSingleOrNull(); + checkIntent(); + if (definition == null) throw ScheduleStartRejected(scheduleId); + } + final preparation = await readSchedulePreparation(_database, actual); + checkIntent(); + final resolution = ScheduleTimeResolver.resolve( + actual, + nowUtc: _now().toUtc(), + ); + if (resolution.instantUtc == null) { + throw ScheduleStartRejected(scheduleId); + } + final interpreted = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + actual, + PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: resolution, + ); + if (interpreted.cacheFingerprint != expectedFingerprint) { + throw ScheduleStartRejected(scheduleId); + } + } + if (joined.retainedRecurringReference && !existing.isStarted) { + throw ScheduleStartRejected(scheduleId); + } if (existing.doneStatus != ScheduleDoneStatus.notEnded.name) { throw ScheduleStartRejected(scheduleId); } - final firstStartedAt = existing.startedAt ?? startedAt ?? DateTime.now(); + // An explicit start supplies the new run timestamp; automatic starts do + // not possess authority to clear a restore confirmation requirement. + if (existing.requiresStartConfirmation && startedAt == null) { + throw ScheduleStartRejected(scheduleId); + } + final firstStartedAt = (existing.startedAt ?? startedAt ?? _now()).toUtc(); if (existing.isStarted && existing.startedAt != null && existing.preparationFrozen) { return firstStartedAt; } + final evaluationNow = _now().toUtc(); + final time = ScheduleTimeResolver.resolve( + joined.toScheduleEntity(), + nowUtc: evaluationNow, + ); + if (time.instantUtc == null) { + throw ScheduleStartRejected(scheduleId); + } + var selectedOffset = existing.occurrenceOffsetSeconds; + if (selectedOffset == null) { + if (time.instantUtc!.isBefore(evaluationNow) || + existing.isStarted || + existing.preparationFrozen || + existing.startedAt != null || + time.occurrences.length != 1) { + throw ScheduleStartRejected(scheduleId); + } + selectedOffset = time.occurrences.single.offsetSeconds; + } + checkIntent(); await _scheduleDao.updateSchedule( existing.copyWith( + occurrenceOffsetSeconds: Value(selectedOffset), isStarted: true, startedAt: Value(firstStartedAt), preparationFrozen: true, ), ); + checkIntent(); + if (existing.requiresStartConfirmation) { + await (_database.update( + _database.schedules, + )..where((t) => t.id.equals(scheduleId))).write( + const SchedulesCompanion(requiresStartConfirmation: Value(false)), + ); + } + checkIntent(); await _userDao.markDurableDataChanged(localProfileId); + checkIntent(); return firstStartedAt; }); @@ -137,23 +305,30 @@ class ScheduleRepositoryImpl implements ScheduleRepository { ScheduleEntity schedule, { bool includePreparationSource = false, }) async { - await _scheduleDao.updateScheduleWithPlace( - schedule.toScheduleWithPlaceRow(), - ); - // Retain the old content-free identity until the session validator sees - // this edit. Deleting it would make an invalid run look like a fresh one - // and allow automatic catch-up. Unchanged timing/shape stays resumable. - await _userDao.markDurableDataChanged(localProfileId); + await _database.writeTransaction(() async { + await _scheduleDao.updateScheduleWithPlace( + schedule.toScheduleWithPlaceRow(), + ); + // Retain the old content-free identity until the session validator sees + // this edit. Deleting it would make an invalid run look like a fresh one + // and allow automatic catch-up. Unchanged timing/shape stays resumable. + await _userDao.markDurableDataChanged(localProfileId); + }); } @override Future finishSchedule(String scheduleId, int latenessTime) async { - await _database.transaction(() async { + final generation = LocalDataOperationGate.shared.captureWrite(); + await _database.writeTransaction(() async { final existing = await _scheduleDao.getScheduleById(scheduleId); if (existing.schedule.doneStatus != ScheduleDoneStatus.notEnded.name) { return; } + if (existing.retainedRecurringReference && !existing.schedule.isStarted) { + throw ScheduleStartRejected(scheduleId); + } + final doneStatus = latenessTime > 0 ? ScheduleDoneStatus.lateEnd : ScheduleDoneStatus.normalEnd; @@ -175,7 +350,9 @@ class ScheduleRepositoryImpl implements ScheduleRepository { } await _userDao.markDurableDataChanged(localProfileId); }); - await _clearTimedPreparation(scheduleId); + if (generation == LocalDataOperationGate.shared.generation) { + await _clearTimedPreparation(scheduleId); + } } Future _clearTimedPreparation(String scheduleId) async { @@ -186,8 +363,18 @@ class ScheduleRepositoryImpl implements ScheduleRepository { } } - Future dispose() async { - await _subscription.cancel(); - await _scheduleStreamController.close(); + Future dispose() => + _disposeFlight ??= _dispose().whenComplete(() => _disposeFlight = null); + Future _dispose() async { + _disposed = true; + LocalDataOperationGate.shared.removeListener(_observeCurrentGeneration); + _subscriptionOwner = null; + _subscriptionOwner = null; + _retiredWatches.retire(_subscription); + _subscription = null; + await Future.wait([ + _retiredWatches.close(), + _subjectClose ??= _scheduleStreamController.close(), + ]); } } diff --git a/lib/data/repositories/schedule_time_correction_repository_impl.dart b/lib/data/repositories/schedule_time_correction_repository_impl.dart new file mode 100644 index 00000000..f81815e6 --- /dev/null +++ b/lib/data/repositories/schedule_time_correction_repository_impl.dart @@ -0,0 +1,363 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/time/time_correction_conflicts.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'time_correction_conflict_reader.dart'; + +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_reference_policy.dart'; +import 'package:on_time_front/domain/repositories/schedule_aggregate_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_time_correction_repository.dart'; + +@Singleton(as: ScheduleTimeCorrectionRepository) +class ScheduleTimeCorrectionRepositoryImpl + implements ScheduleTimeCorrectionRepository { + ScheduleTimeCorrectionRepositoryImpl( + this.db, + this.aggregates, { + @ignoreParam LocalDataOperationGate? gate, + @ignoreParam DateTime Function()? now, + }) : gate = gate ?? LocalDataOperationGate.shared, + now = now ?? DateTime.now; + + final AppDatabase db; + final ScheduleAggregateRepository aggregates; + final LocalDataOperationGate gate; + final DateTime Function() now; + + Never _reject(ScheduleSaveFailure failure) => + throw ScheduleSaveRejected(failure); + + void _available() { + if (gate.isInvalidated || gate.isReplacingData || gate.isRecoveryPending) { + _reject(ScheduleSaveFailure.unavailable); + } + } + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => + !gate.isInvalidated && + !gate.isReplacingData && + !gate.isRecoveryPending && + receipt.generation == gate.generation; + + void _repairable( + ScheduleEntity schedule, + ScheduleTimeResolution resolution, + DateTime at, + ) { + if (RecurrenceReferencePolicy.hasProtectedFacts(schedule) || + resolution.isHistorical || + schedule.startedAt != null || + schedule.finishedAt != null || + schedule.scoreContributionRecorded || + schedule.retainedRecurringReference || + RecurrenceReferencePolicy.isProvablyPast(schedule, at)) { + _reject(ScheduleSaveFailure.protected); + } + switch (resolution.status) { + case ScheduleTimeResolutionStatus.unknownZone: + case ScheduleTimeResolutionStatus.nonexistent: + case ScheduleTimeResolutionStatus.ambiguous: + case ScheduleTimeResolutionStatus.changed: + break; + case ScheduleTimeResolutionStatus.resolved: + case ScheduleTimeResolutionStatus.historicalUncertain: + case ScheduleTimeResolutionStatus.invalid: + _reject(ScheduleSaveFailure.protected); + } + } + + @override + Future review(String scheduleId) => + db.transaction(() async { + _available(); + final identity = TimeZoneRules.loadedIdentity; + final at = now().toUtc(); + final snapshot = await aggregates.readForEdit(scheduleId); + final resolution = ScheduleTimeResolver.resolve( + snapshot.schedule, + nowUtc: at, + ); + ScheduleSaveFailure? blockedBy; + try { + _repairable(snapshot.schedule, resolution, at); + } on ScheduleSaveRejected catch (failure) { + blockedBy = failure.failure; + } + _available(); + if (snapshot.baseline.generation != gate.generation || + identity != TimeZoneRules.loadedIdentity) { + _reject(ScheduleSaveFailure.conflict); + } + return ScheduleTimeCorrectionReview( + snapshot: snapshot, + resolution: resolution, + ruleIdentity: identity, + validationNowUtc: at, + blockedBy: blockedBy, + ); + }); + + @override + Future reviewChoice( + ScheduleTimeCorrectionCommand command, + ) => db.transaction(() async { + _available(); + final at = now().toUtc(); + final snapshot = await aggregates.readForEdit( + command.review.snapshot.schedule.id, + ); + if (snapshot.baseline != command.review.snapshot.baseline) { + _reject(ScheduleSaveFailure.conflict); + } + final current = ScheduleTimeResolver.resolve(snapshot.schedule, nowUtc: at); + _validateAuthority(command, current, at, TimeZoneRules.loadedIdentity); + _repairable(snapshot.schedule, current, at); + final desired = snapshot.schedule.copyWith( + scheduleTime: command.civil.toUtcCarrier(), + timeZoneId: command.timeZoneId, + occurrenceOffsetSeconds: command.offsetSeconds, + ); + final time = ScheduleTimeResolver.resolve(desired, nowUtc: at); + final instant = time.instantUtc; + final lead = + snapshot.preparation.totalDuration + + desired.moveTime + + (desired.scheduleSpareTime ?? Duration.zero); + if (time.status != ScheduleTimeResolutionStatus.resolved || + time.isHistorical || + instant == null || + !instant.subtract(lead).isAfter(at)) { + _reject(ScheduleSaveFailure.invalid); + } + final budget = BackupBudget(); + final world = await readTimeCorrectionConflictWorld( + db: db, + nowUtc: at, + budget: budget, + ignoredIds: {desired.id}, + ); + final offsets = time.occurrences; + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: command.civil.toUtcCarrier(), + timeZoneId: command.timeZoneId, + count: 1, + repeatedTime: offsets.length > 1 + ? offsets.first.offsetSeconds == command.offsetSeconds + ? RepeatedCivilTime.first + : RepeatedCivilTime.second + : null, + ); + final proof = await TimeCorrectionConflictScanner.scan( + rule: rule, + base: desired, + basePreparation: snapshot.preparation.totalDuration, + proposedOverrides: {rule.start.toIso8601String(): desired}, + proposedPreparationById: {desired.id: snapshot.preparation.totalDuration}, + excludedSlots: {}, + storedOthers: world.rows, + otherSegments: world.segments, + otherExcludedSlots: world.exclusions, + nowUtc: at, + budget: budget, + ); + final finalNow = now().toUtc(); + if (finalNow.isBefore(at) || !instant.subtract(lead).isAfter(finalNow)) { + _reject(ScheduleSaveFailure.conflict); + } + _validateAuthority( + command, + ScheduleTimeResolver.resolve(snapshot.schedule, nowUtc: finalNow), + finalNow, + TimeZoneRules.loadedIdentity, + ); + _available(); + return TimeCorrectionConflictProof( + conflicts: proof.conflicts, + through: proof.through, + workUnits: budget.work, + possibleOverlaps: [ + ...world.possibleOverlaps, + ...proof.possibleOverlaps, + ].where((e) => e.mayOverlap(instant.subtract(lead), instant)).toList(), + ); + }); + + String _digest(ScheduleTimeCorrectionCommand command) => sha256 + .convert( + utf8.encode( + jsonEncode([ + 'time-correction-v1', + command.review.snapshot.schedule.id, + command.review.snapshot.baseline.props, + command.review.ruleIdentity, + command.review.validationNowUtc.toUtc().toIso8601String(), + command.civil.toCivilIso8601String(), + command.timeZoneId, + command.offsetSeconds, + command.acknowledgedUncertainIds.toList()..sort(), + ]), + ), + ) + .toString(); + + @override + Future confirm(ScheduleTimeCorrectionCommand command) => + db.transaction(() async { + _available(); + final expected = command.review.snapshot.baseline; + final id = command.review.snapshot.schedule.id; + if (command.mutationId.isEmpty || + expected.incarnation == null || + expected.version == null) { + _reject(ScheduleSaveFailure.invalid); + } + final profile = await (db.select( + db.users, + )..where((t) => t.id.equals(localProfileId))).getSingle(); + if (profile.storeIncarnation != expected.store || + gate.generation != expected.generation) { + _reject(ScheduleSaveFailure.conflict); + } + final row = await (db.select( + db.schedules, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (row == null || row.aggregateIncarnation != expected.incarnation) { + _reject(ScheduleSaveFailure.conflict); + } + final digest = _digest(command); + // A retry acknowledges the original commit; it does not reinterpret or + // write again if time/rules have moved since that successful command. + if (row.lastMutationId == command.mutationId && + row.lastMutationDigest == digest && + row.lastMutationVersion == row.aggregateVersion) { + return ScheduleSaveReceipt( + scheduleId: id, + mutationId: command.mutationId, + generation: gate.generation, + changed: false, + ); + } + if (row.lastMutationId == command.mutationId) { + _reject(ScheduleSaveFailure.conflict); + } + final current = await aggregates.readForEdit(id); + if (current.baseline != expected) { + _reject(ScheduleSaveFailure.conflict); + } + final evaluationNow = now().toUtc(); + final rules = TimeZoneRules.loadedIdentity; + final original = ScheduleTimeResolver.resolve( + current.schedule, + nowUtc: evaluationNow, + ); + _validateAuthority(command, original, evaluationNow, rules); + _repairable(current.schedule, original, evaluationNow); + final desired = current.schedule.copyWith( + scheduleTime: command.civil.toUtcCarrier(), + timeZoneId: command.timeZoneId, + occurrenceOffsetSeconds: command.offsetSeconds, + ); + final lead = + current.preparation.totalDuration + + current.schedule.moveTime + + (current.schedule.scheduleSpareTime ?? Duration.zero); + void validateChoice(DateTime at) { + final result = ScheduleTimeResolver.resolve(desired, nowUtc: at); + final instant = result.instantUtc; + if (result.status != ScheduleTimeResolutionStatus.resolved || + result.isHistorical || + instant == null || + !instant.isAfter(at) || + !instant.subtract(lead).isAfter(at)) { + _reject(ScheduleSaveFailure.invalid); + } + } + + validateChoice(evaluationNow); + final proof = await reviewChoice(command); + if (proof.conflicts.isNotEmpty) { + throw ScheduleTimeCorrectionConflict(proof); + } + if (!const SetEquality().equals( + command.acknowledgedUncertainIds, + proof.possibleOverlaps.map((e) => e.id).toSet(), + )) { + _reject(ScheduleSaveFailure.invalid); + } + final overrides = current.schedule.recurringOverrides + .split(',') + .where((value) => value.isNotEmpty) + .toSet(); + if (current.schedule.isRecurring) overrides.add('time'); + await (db.update(db.schedules)..where((t) => t.id.equals(id))).write( + SchedulesCompanion( + scheduleTime: Value(command.civil.toUtcCarrier()), + timeZoneId: Value(command.timeZoneId), + occurrenceOffsetSeconds: Value(command.offsetSeconds), + recurringOverrides: Value((overrides.toList()..sort()).join(',')), + ), + ); + await db.userDao.markDurableDataChanged(localProfileId); + final saved = await (db.select( + db.schedules, + )..where((t) => t.id.equals(id))).getSingle(); + await (db.update(db.schedules)..where((t) => t.id.equals(id))).write( + SchedulesCompanion( + lastMutationId: Value(command.mutationId), + lastMutationDigest: Value(digest), + lastMutationVersion: Value(saved.aggregateVersion), + ), + ); + // No OS side effect occurs in this transaction. If an awaited write + // crosses a meaningful boundary, every write above is rolled back. + final finalNow = now().toUtc(); + if (finalNow.isBefore(evaluationNow)) { + _reject(ScheduleSaveFailure.conflict); + } + _validateAuthority( + command, + ScheduleTimeResolver.resolve(current.schedule, nowUtc: finalNow), + finalNow, + TimeZoneRules.loadedIdentity, + ); + _repairable(current.schedule, original, finalNow); + validateChoice(finalNow); + _available(); + if (gate.generation != expected.generation) { + _reject(ScheduleSaveFailure.conflict); + } + return ScheduleSaveReceipt( + scheduleId: id, + mutationId: command.mutationId, + generation: gate.generation, + changed: true, + ); + }); + + void _validateAuthority( + ScheduleTimeCorrectionCommand command, + ScheduleTimeResolution actual, + DateTime at, + String ruleIdentity, + ) { + if (at.isBefore(command.review.validationNowUtc.toUtc()) || + ruleIdentity != command.review.ruleIdentity || + actual != command.review.resolution) { + _reject(ScheduleSaveFailure.conflict); + } + } +} diff --git a/lib/data/repositories/time_correction_conflict_reader.dart b/lib/data/repositories/time_correction_conflict_reader.dart new file mode 100644 index 00000000..85517cfc --- /dev/null +++ b/lib/data/repositories/time_correction_conflict_reader.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_correction_conflicts.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/data/mappers/recurrence_codec.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; + +/// Active-store read adapter only. Staging supplies its own authenticated graph +/// to the pure scanner instead of calling this adapter or an active repository. +final class TimeCorrectionConflictWorld { + const TimeCorrectionConflictWorld( + this.rows, + this.segments, + this.exclusions, + this.possibleOverlaps, + ); + final List rows; + final List segments; + final Map> exclusions; + final List possibleOverlaps; + Set get unresolvedIds => possibleOverlaps.map((e) => e.id).toSet(); +} + +Future readTimeCorrectionConflictWorld({ + required AppDatabase db, + required DateTime nowUtc, + required BackupBudget budget, + Set ignoredIds = const {}, + Map closingSeriesAt = const {}, +}) async { + final preparations = _BoundedPreparations(db, budget); + final stored = await db.scheduleDao.getScheduleList( + limit: BackupLimits.schedules + 1, + ); + if (stored.length > BackupLimits.schedules) { + BackupLimits.exceeded('schedules'); + } + budget.visit(stored.length); + final rawSegments = await (db.select( + db.recurringScheduleSegments, + )..limit(BackupLimits.records + 1)).get(); + if (rawSegments.length + stored.length > BackupLimits.records) { + BackupLimits.exceeded('records'); + } + budget.visit(rawSegments.length); + final rawExclusions = await (db.select( + db.recurringScheduleExclusions, + )..limit(BackupLimits.records + 1)).get(); + if (rawSegments.length + rawExclusions.length + stored.length > + BackupLimits.records) { + BackupLimits.exceeded('records'); + } + budget.visit(rawExclusions.length); + final exclusions = >{}; + for (final value in rawExclusions) { + (exclusions[value.segmentId] ??= {}).add(value.slotKey); + } + final unresolved = []; + final rows = []; + for (final joined in stored) { + final value = joined.toScheduleEntity(); + // Every materialized reference owns its original slot, including unresolved + // overrides. Its uncertainty must never resurrect the base appointment. + if (value.recurringSegmentId != null && value.recurringSlotKey != null) { + (exclusions[value.recurringSegmentId!] ??= {}).add( + value.recurringSlotKey!, + ); + } + if (ignoredIds.contains(value.id)) continue; + final active = + value.isStarted && value.startedAt != null && value.preparationFrozen; + if (value.retainedRecurringReference && !active) continue; + final resolution = ScheduleTimeResolver.resolve(value, nowUtc: nowUtc); + final instant = resolution.instantUtc; + if (instant == null) { + if (value.doneStatus != ScheduleDoneStatus.notEnded || + (resolution.isHistorical && !active)) { + continue; + } + final preparation = await preparations.schedule(value); + final lead = + preparation.totalDuration + + value.moveTime + + (value.scheduleSpareTime ?? Duration.zero); + DateTime? earliest, latest; + try { + final civil = CivilDateTime.fromFields( + value.scheduleTime, + ).toUtcCarrier(); + earliest = civil.subtract(const Duration(days: 1) + lead); + latest = active ? null : civil.add(const Duration(days: 1)); + } on FormatException { + /* Unknown range remains a possible impact. */ + } + if (latest != null && latest.isBefore(nowUtc)) continue; + unresolved.add( + TimeCorrectionPossibleOverlap( + id: value.id, + name: value.scheduleName, + reason: resolution.status.name, + originalCivil: value.scheduleTime, + timeZoneId: value.timeZoneId, + earliestPreparationUtc: earliest, + latestTargetUtc: latest, + ), + ); + continue; + } + final preparation = await preparations.schedule(value); + budget.visit(preparation.preparationStepList.length + 1); + if (preparation.preparationStepList.length > + BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + rows.add( + TimeCorrectionBusyInterval( + value, + instant.subtract( + preparation.totalDuration + + value.moveTime + + (value.scheduleSpareTime ?? Duration.zero), + ), + instant, + ), + ); + } + final segments = []; + for (final raw in rawSegments) { + final value = RecurringSegment( + id: raw.id, + seriesId: raw.seriesId, + rule: RecurrenceCodec.ruleFromJson(jsonDecode(raw.ruleJson)), + schedule: RecurrenceCodec.scheduleFromJson(jsonDecode(raw.scheduleJson)), + preparation: await preparations.definition(raw.preparationId), + preparationId: raw.preparationId, + fromSlot: CivilDateTime.parse(raw.fromSlot).toUtcCarrier(), + beforeSlot: raw.beforeSlot == null + ? null + : CivilDateTime.parse(raw.beforeSlot!).toUtcCarrier(), + createdAt: raw.createdAt, + preparationNotBefore: raw.preparationNotBefore, + ); + budget.visit(value.preparation.preparationStepList.length + 1); + if (value.preparation.preparationStepList.length > + BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + var before = value.beforeSlot; + final close = closingSeriesAt[value.seriesId]; + if (close != null && (before == null || before.isAfter(close))) { + before = close.isBefore(value.fromSlot) ? value.fromSlot : close; + } + if (before != null && !before.isAfter(value.fromSlot)) continue; + if (!TimeZoneRules.contains(value.rule.timeZoneId)) { + final earliest = value.rule.start.subtract( + const Duration(days: 1) + value.leadTime, + ); + final end = before ?? value.rule.until; + final latest = end?.add(const Duration(days: 2)); + if (latest == null || !latest.isBefore(nowUtc)) { + unresolved.add( + TimeCorrectionPossibleOverlap( + id: 'segment:${value.id}', + name: value.schedule.scheduleName, + reason: 'unknownZone', + originalCivil: value.rule.start, + timeZoneId: value.rule.timeZoneId, + rule: value.rule, + earliestPreparationUtc: earliest, + latestTargetUtc: latest, + ), + ); + } + continue; + } + segments.add( + RecurringSegment( + id: value.id, + seriesId: value.seriesId, + rule: value.rule, + schedule: value.schedule, + preparation: value.preparation, + preparationId: value.preparationId, + fromSlot: value.fromSlot, + beforeSlot: before, + createdAt: value.createdAt, + preparationNotBefore: value.preparationNotBefore, + ), + ); + } + // Force malformed bounds through the strict carrier boundary before claims. + for (final keys in exclusions.values) { + for (final key in keys) { + budget.visit(); + CivilDateTime.parse(key); + } + } + return TimeCorrectionConflictWorld( + List.unmodifiable(rows), + List.unmodifiable(segments), + Map.unmodifiable({ + for (final e in exclusions.entries) + e.key: Set.unmodifiable(e.value), + }), + List.unmodifiable(unresolved), + ); +} + +final class _BoundedPreparations { + _BoundedPreparations(this.db, this.budget); + final AppDatabase db; + final BackupBudget budget; + final _cache = {}; + + Future _load( + String key, + String sql, + String owner, { + bool linked = false, + }) async { + if (_cache[key] case final value?) return value; + final rows = await db + .customSelect( + '$sql LIMIT ${BackupLimits.preparationSteps + 1}', + variables: [Variable(owner)], + ) + .get(); + budget.visit(rows.length + 1); + if (rows.length > BackupLimits.preparationSteps) { + BackupLimits.exceeded('preparationSteps'); + } + var minutes = 0; + final steps = []; + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + final amount = BackupLimits.minutes(row.read('minutes')); + minutes = BackupLimits.minutes(minutes + amount); + steps.add( + PreparationStepEntity( + id: row.read('id'), + preparationName: row.read('name'), + preparationTime: Duration(minutes: amount), + nextPreparationId: linked + ? row.readNullable('next_id') + : i + 1 < rows.length + ? rows[i + 1].read('id') + : null, + ), + ); + } + return _cache[key] = PreparationEntity( + preparationStepList: List.unmodifiable(steps), + ); + } + + Future definition(String id) => _load( + 'definition:$id', + 'SELECT id,name,minutes FROM preparation_definition_steps WHERE definition_id = ? ORDER BY position', + id, + ); + Future schedule(ScheduleEntity value) async { + // A frozen run owns its captured steps; a current template/default never + // replaces them just because a mutable preparation source still exists. + if (!value.preparationFrozen && + value.preparationMode == SchedulePreparationMode.template && + value.preparationTemplateId != null && + !value.preparationTemplateDeleted) { + final id = value.preparationTemplateId!; + final exists = await db + .customSelect( + 'SELECT id FROM preparation_templates WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .get(); + budget.visit(); + if (exists.isNotEmpty) { + return _load( + 'template:$id', + 'SELECT id,preparation_name AS name,preparation_time AS minutes FROM preparation_template_steps WHERE template_id = ? ORDER BY position', + id, + ); + } + } + final own = value.preparationDefinitionId != null + ? await definition(value.preparationDefinitionId!) + : await _load( + 'schedule:${value.id}', + 'SELECT id,preparation_name AS name,preparation_time AS minutes,next_preparation_id AS next_id FROM preparation_schedules WHERE schedule_id = ?', + value.id, + linked: true, + ); + if (value.preparationFrozen || own.preparationStepList.isNotEmpty) { + return own; + } + return _load( + 'default', + 'SELECT id,preparation_name AS name,preparation_time AS minutes,next_preparation_id AS next_id FROM preparation_users WHERE user_id = ?', + localProfileId, + linked: true, + ); + } +} diff --git a/lib/data/repositories/user_repository_impl.dart b/lib/data/repositories/user_repository_impl.dart index c80fb4cd..6214641a 100644 --- a/lib/data/repositories/user_repository_impl.dart +++ b/lib/data/repositories/user_repository_impl.dart @@ -1,3 +1,7 @@ +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/core/startup/subscription_cleanup.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:async'; import 'package:injectable/injectable.dart'; @@ -8,12 +12,18 @@ import 'package:on_time_front/domain/entities/user_entity.dart'; import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:rxdart/subjects.dart'; -@Singleton(as: UserRepository) +Future disposeUserRepository(UserRepository resource) => + StartupDependencyScope.release( + resource, + (resource as UserRepositoryImpl).dispose, + ); + +@Singleton(as: UserRepository, dispose: disposeUserRepository) class UserRepositoryImpl implements UserRepository { UserRepositoryImpl(this._database) : _userDao = _database.userDao { - _subscription = _userDao.watchUserById(localProfileId).listen((user) { - if (user != null) _userStreamController.add(user); - }); + StartupDependencyScope.own(this, dispose); + LocalDataOperationGate.shared.addListener(_observeCurrentGeneration); + _observeCurrentGeneration(); } final AppDatabase _database; @@ -21,16 +31,72 @@ class UserRepositoryImpl implements UserRepository { final _userStreamController = BehaviorSubject.seeded( const UserEntity.empty(), ); - late final StreamSubscription _subscription; + StreamSubscription? _subscription; + int _watchGeneration = -1; + Object? _subscriptionOwner; + Object _publicationOwner = Object(); + + void _publish(UserEntity user) { + _publicationOwner = Object(); + if (!_userStreamController.isClosed) _userStreamController.add(user); + } + + void _publishRead(UserEntity user, Object owner) { + // A newer watch/read publication wins over a delayed one-shot reload. + if (identical(owner, _publicationOwner)) _publish(user); + } + + final _retiredWatches = SubscriptionCleanup(); + bool _disposed = false; + Future? _subjectClose; + Future? _disposeFlight; + void _observeCurrentGeneration() { + if (_disposed) return; + final gate = LocalDataOperationGate.shared; + if (_watchGeneration != gate.generation || + gate.isRecoveryPending || + gate.isInvalidated) { + _subscriptionOwner = null; + _retiredWatches.retire(_subscription); + _subscription = null; + _publish(const UserEntity.empty()); + } + if (_subscription != null || + gate.isReplacingData || + gate.isRecoveryPending || + gate.isInvalidated) { + return; + } + final generation = gate.generation; + _watchGeneration = generation; + final owner = Object(); + _subscriptionOwner = owner; + _subscription = _userDao.watchUserById(localProfileId).listen((user) { + if (identical(owner, _subscriptionOwner) && + generation == gate.generation && + !gate.isReplacingData && + !gate.isRecoveryPending && + !gate.isInvalidated && + user != null) { + _publish(user); + } + }); + } @override Stream get userStream => _userStreamController.stream; @override Future getUser() async { + final gate = LocalDataOperationGate.shared; + final generation = gate.captureWrite(); + final publication = _publicationOwner; final existing = await _userDao.getUserById(localProfileId); + gate.checkWrite(generation); if (existing != null) { - _userStreamController.add(existing); + await RestoreRuntimeIdentity.shared.load(_database); + gate.checkWrite(generation); + _publishRead(existing, publication); return existing; } @@ -39,26 +105,43 @@ class UserRepositoryImpl implements UserRepository { spareTime: Duration.zero, note: '', ); - await _userDao.putUser(profile); + gate.checkWrite(generation); + await _database.writeTransaction(() async { + gate.checkWrite(generation); + await _userDao.putUser(profile); + }); + await RestoreRuntimeIdentity.shared.load(_database); final stored = (await _userDao.getUserById(localProfileId))!; - _userStreamController.add(stored); + gate.checkWrite(generation); + _publishRead(stored, publication); return stored; } @override Future updateSpareTime(Duration spareTime) async { - await _userDao.updateSpareTime(localProfileId, spareTime); + await _database.writeTransaction( + () => _userDao.updateSpareTime(localProfileId, spareTime), + ); } @override Future resetLocalData() async { await _database.deleteAllDurableData(); - _userStreamController.add(const UserEntity.empty()); + _publish(const UserEntity.empty()); await getUser(); } - Future dispose() async { - await _subscription.cancel(); - await _userStreamController.close(); + Future dispose() => + _disposeFlight ??= _dispose().whenComplete(() => _disposeFlight = null); + Future _dispose() async { + _disposed = true; + LocalDataOperationGate.shared.removeListener(_observeCurrentGeneration); + _subscriptionOwner = null; + _retiredWatches.retire(_subscription); + _subscription = null; + await Future.wait([ + _retiredWatches.close(), + _subjectClose ??= _userStreamController.close(), + ]); } } diff --git a/lib/data/tables/schedule_with_place_model.dart b/lib/data/tables/schedule_with_place_model.dart index 11fe1bc0..2a683e72 100644 --- a/lib/data/tables/schedule_with_place_model.dart +++ b/lib/data/tables/schedule_with_place_model.dart @@ -2,15 +2,20 @@ import 'package:equatable/equatable.dart'; import 'package:on_time_front/core/database/database.dart'; class ScheduleWithPlace extends Equatable { - const ScheduleWithPlace({required this.schedule, required this.place}); + const ScheduleWithPlace({ + required this.schedule, + required this.place, + this.retainedRecurringReference = false, + }); // The classes are generated by drift for each of the tables involved in the // join. final Schedule schedule; final Place place; + final bool retainedRecurringReference; @override - List get props => [schedule, place]; + List get props => [schedule, place, retainedRecurringReference]; @override String toString() { diff --git a/lib/data/tables/schedules_table.dart b/lib/data/tables/schedules_table.dart index 1a6baf7a..83a159b0 100644 --- a/lib/data/tables/schedules_table.dart +++ b/lib/data/tables/schedules_table.dart @@ -6,6 +6,8 @@ import 'package:uuid/uuid.dart'; @TableIndex(name: 'schedules_schedule_time_idx', columns: {#scheduleTime}) @TableIndex(name: 'schedules_place_id_idx', columns: {#placeId}) class Schedules extends Table { + BoolColumn get requiresStartConfirmation => + boolean().withDefault(const Constant(false))(); // Installation-local concurrency metadata; excluded from portable backups. TextColumn get aggregateIncarnation => text().nullable()(); IntColumn get aggregateVersion => integer().nullable()(); diff --git a/lib/data/tables/user_table.dart b/lib/data/tables/user_table.dart index ebadf67d..88d28777 100644 --- a/lib/data/tables/user_table.dart +++ b/lib/data/tables/user_table.dart @@ -2,6 +2,10 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; class Users extends Table { + BoolColumn get restoreCleanupPending => + boolean().withDefault(const Constant(false))(); + BoolColumn get rejectLegacyDelivery => + boolean().withDefault(const Constant(false))(); TextColumn get storeIncarnation => text().nullable()(); TextColumn get id => text().clientDefault(() => Uuid().v7())(); IntColumn get spareTime => integer()(); diff --git a/lib/domain/entities/alarm_entities.dart b/lib/domain/entities/alarm_entities.dart index 3a2f7831..91913472 100644 --- a/lib/domain/entities/alarm_entities.dart +++ b/lib/domain/entities/alarm_entities.dart @@ -4,7 +4,7 @@ import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; const alarmDefaultOffset = Duration(minutes: 5); -const alarmLaunchPayloadVersion = '9'; +const alarmLaunchPayloadVersion = '10'; enum AlarmProvider { androidAlarmManager, iosAlarmKit, localNotification, none } @@ -34,6 +34,7 @@ enum AlarmFailureReason { scheduleInvalid, cancellationFailed, observationFailed, + contentDeferred, platformError, unknown, } @@ -129,6 +130,8 @@ extension AlarmFailureReasonWireValue on AlarmFailureReason { return 'scheduleInvalid'; case AlarmFailureReason.observationFailed: return 'observationFailed'; + case AlarmFailureReason.contentDeferred: + return 'contentDeferred'; case AlarmFailureReason.cancellationFailed: return 'cancellationFailed'; case AlarmFailureReason.platformError: @@ -146,6 +149,8 @@ extension AlarmFailureReasonWireValue on AlarmFailureReason { case 'scheduleInvalid': case 'SCHEDULE_INVALID': return AlarmFailureReason.scheduleInvalid; + case 'contentDeferred': + return AlarmFailureReason.contentDeferred; case 'observationFailed': case 'OBSERVATION_FAILED': return AlarmFailureReason.observationFailed; @@ -451,6 +456,7 @@ class AlarmReconciliationResult extends Equatable { } bool isAlarmEligibleSchedule(ScheduleWithPreparationEntity schedule) { + if (schedule.retainedRecurringReference) return false; return schedule.doneStatus == ScheduleDoneStatus.notEnded; } @@ -483,6 +489,7 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( bool detailedNotificationContent = false, String? currentTimeZoneId, String languageCode = 'en', + String? storeIncarnation, }) { final alarmTime = computeAlarmTime(schedule, offset: alarmOffset); final id = stableAlarmId(schedule.id); @@ -491,8 +498,7 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( scheduleTitle: schedule.scheduleName, detailed: detailedNotificationContent, languageCode: languageCode, - displayTimeZone: - currentTimeZoneId != null && currentTimeZoneId != schedule.timeZoneId + displayTimeZone: currentTimeZoneId != schedule.timeZoneId ? schedule.timeZoneId : null, ); @@ -513,10 +519,10 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( 'type': 'schedule_notification', 'alarmLaunchPayloadVersion': alarmLaunchPayloadVersion, 'scheduleId': schedule.id, + 'storeIncarnation': ?storeIncarnation, 'promptVariant': 'notification', 'detailedNotificationContent': detailedNotificationContent.toString(), if (detailedNotificationContent && - currentTimeZoneId != null && currentTimeZoneId != schedule.timeZoneId) 'notificationTimeZone': schedule.timeZoneId, }, diff --git a/lib/domain/entities/backup_operation.dart b/lib/domain/entities/backup_operation.dart index 980546a5..84d25f0b 100644 --- a/lib/domain/entities/backup_operation.dart +++ b/lib/domain/entities/backup_operation.dart @@ -1,3 +1,5 @@ +import 'backup_restore_selection.dart'; + enum BackupFreshness { neverExported, noChanges, unexportedChanges } enum BackupExportResult { cancelled, saved, savedFreshnessUpdateFailed } @@ -17,23 +19,55 @@ class BackupFreshnessStatus { class BackupRestorePreview { const BackupRestorePreview({ required this.cutoff, + this.cutoffLiteral, required this.sourceAppVersion, required this.sourcePlatform, required this.scheduleCount, required this.templateCount, required this.defaultPreparationStepCount, + this.timezoneChangeCount = 0, + this.uncertainHistoryCount = 0, + this.uncertainHistoryExamples = const [], }); final DateTime cutoff; + final String? cutoffLiteral; final String sourceAppVersion; final String sourcePlatform; final int scheduleCount; final int templateCount; final int defaultPreparationStepCount; + final int timezoneChangeCount; + final int uncertainHistoryCount; + final List uncertainHistoryExamples; +} + +enum BackupHistoryUncertaintyReason { + historicalProvenance, + unavailableHistoricalZone, + protectedStartZone, + historicalOrdinal, +} + +class BackupHistoryUncertainty { + const BackupHistoryUncertainty({ + this.name, + required this.civil, + required this.zone, + this.reason = BackupHistoryUncertaintyReason.historicalProvenance, + }); + final String? name; + final DateTime civil; + final String zone; + final BackupHistoryUncertaintyReason reason; } -abstract class BackupRestoreInput { +abstract class BackupRestoreInput extends BackupRestoreSelection { BackupRestorePreview get preview; + @override + Future dispose() async {} + Future timeZoneImpacts({String? cursor}) async => + const BackupTimeZoneImpactPage([]); } enum DataOperationFailure { @@ -55,15 +89,106 @@ class DataOperationException implements Exception { final bool followUpPending; } +enum BackupCommitDisposition { notCommitted, committed, undetermined } + +/// The replacement call failed and its durable outcome could not be read back. +/// Only a fresh startup authority may resolve this outcome; do not import again. +class BackupRestoreCommitUncertain implements Exception { + const BackupRestoreCommitUncertain({required this.generation}); + final int generation; +} + +enum RecoveryFollowUp { awaitingNewProcess, verifyingPair, cleanupPending } + class BackupRestoreReceipt { - const BackupRestoreReceipt({ - required this.committed, - required this.generation, - this.followUpPending = false, + factory BackupRestoreReceipt({ + required BackupCommitDisposition disposition, + required int generation, + bool followUpPending = false, + DataOperationFailure? failure, + }) { + if (disposition == BackupCommitDisposition.undetermined) { + throw ArgumentError( + 'Use the uncertain receipt for unknown commit authority', + ); + } + return BackupRestoreReceipt._( + disposition, + generation, + followUpPending, + failure, + ); + } + const BackupRestoreReceipt._( + this.disposition, + this.generation, + this.followUpPending, this.failure, - }); - final bool committed; + ) : recoveryFollowUp = null; + + const BackupRestoreReceipt.uncertain({required this.generation}) + : disposition = BackupCommitDisposition.undetermined, + followUpPending = true, + failure = null, + recoveryFollowUp = null; + + const BackupRestoreReceipt.recovery({ + required this.generation, + required RecoveryFollowUp phase, + }) : disposition = BackupCommitDisposition.committed, + followUpPending = true, + failure = null, + recoveryFollowUp = phase; + + final BackupCommitDisposition disposition; + final RecoveryFollowUp? recoveryFollowUp; + // A nullable compatibility observation cannot accidentally make unknown false. + bool? get committed => switch (disposition) { + BackupCommitDisposition.committed => true, + BackupCommitDisposition.notCommitted => false, + BackupCommitDisposition.undetermined => null, + }; final int generation; final bool followUpPending; final DataOperationFailure? failure; } + +/// The encrypted candidate could not be released. This is not a DB commit or +/// notification-delivery receipt. Causes are retained for controlled diagnostics. +final class RestoreStagingCleanupFailure implements Exception { + const RestoreStagingCleanupFailure({ + this.originalError, + required this.cleanupError, + this.retryCleanup, + }); + final Object? originalError; + final Object cleanupError; + final Future Function()? retryCleanup; +} + +/// The preserved source became usable while a damaged-store preview was open. +/// The user must confirm a new preview through the normal A09 restore workflow. +final class RecoveryOriginalAvailable implements Exception { + const RecoveryOriginalAvailable(); +} + +class BackupTimeZoneImpact { + const BackupTimeZoneImpact({ + required this.name, + required this.civil, + required this.zone, + required this.previousOffset, + required this.newOffset, + }); + final String name; + final DateTime civil; + final String zone; + final int? previousOffset; + final int newOffset; +} + +class BackupTimeZoneImpactPage { + const BackupTimeZoneImpactPage(this.items, {this.nextCursor}); + final List items; + final String? nextCursor; +} diff --git a/lib/domain/entities/backup_processing.dart b/lib/domain/entities/backup_processing.dart new file mode 100644 index 00000000..b3f4ae8f --- /dev/null +++ b/lib/domain/entities/backup_processing.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'backup_operation.dart'; + +/// A reason safe to render without exposing file contents or platform paths. +enum BackupFailureKind { + userCancelled, + resourceLimit, + unsupportedRepresentation, + authentication, + dataInvariant, + timeZoneChoiceRequired, + inputOutput, +} + +/// FormatException compatibility is retained for existing small-input callers; +/// product UI uses [kind], never the platform cause or raw message. +class BackupProcessingFailure extends FormatException { + const BackupProcessingFailure(this.kind, [this.limit]) + : super('Backup processing could not complete'); + final BackupFailureKind kind; + final String? limit; +} + +enum BackupProcessingPhase { + reading, + validating, + encrypting, + choosingDestination, + preview, + applying, + cancelling, + cleanupPending, +} + +/// One installation-process backup resource lease, not a DB writer gate. The +/// candidate inherits the same lease so opening previews cannot accumulate KDFs +/// or encrypted stores. A service, not a widget, owns terminal cleanup. +class BackupProcessingOwner { + static final shared = BackupProcessingOwner(); + BackupProcessingLease? _active; + final _changes = StreamController.broadcast( + sync: true, + ); + Stream get changes => _changes.stream; + BackupProcessingLease? get active => _active; + + BackupProcessingLease acquire() { + if (_active != null) { + throw const DataOperationException(DataOperationFailure.busy); + } + final lease = BackupProcessingLease._(this); + _active = lease; + _changes.add(lease.phase); + return lease; + } + + void requestCancellation() => _active?.requestCancellation(); + Future retryCleanup() async => _active?.retryCleanup(); +} + +class BackupProcessingLease { + BackupProcessingLease._(this._owner); + final BackupProcessingOwner _owner; + BackupProcessingPhase phase = BackupProcessingPhase.reading; + bool _cancelled = false; + final _cancellation = Completer(); + Future get cancellationRequested => _cancellation.future; + bool _released = false; + bool cancellationAllowed = true; + final _cleanup = Function()>[]; + var _operations = 0; + Completer? _idle; + bool _releaseRequested = false; + + void beginOperation() { + if (_released) return; + if (_operations++ == 0) _idle = Completer(); + } + + void endOperation() { + if (_operations == 0) return; + if (--_operations == 0) { + _idle?.complete(); + _idle = null; + if (_releaseRequested && _cleanup.isEmpty) release(); + } + } + + Future? _cleanupFlight; + + void update(BackupProcessingPhase value) { + if (_released) return; + phase = _cancelled && cancellationAllowed + ? BackupProcessingPhase.cancelling + : value; + _owner._changes.add(phase); + } + + void requestCancellation() { + if (_released || !cancellationAllowed) return; + _cancelled = true; + if (!_cancellation.isCompleted) _cancellation.complete(); + update(BackupProcessingPhase.cancelling); + } + + void check() { + if (_cancelled) { + throw const BackupProcessingFailure(BackupFailureKind.userCancelled); + } + } + + void retainCleanup(Future Function() cleanup) { + if (_released) throw StateError('Cannot abandon cleanup after release'); + _cleanup.add(cleanup); + cancellationAllowed = false; + update(BackupProcessingPhase.cleanupPending); + } + + Future retryCleanup() => _cleanupFlight ??= _retryCleanup() + .whenComplete(() => _cleanupFlight = null); + + Future _retryCleanup() async { + // The operation may still be closing a provider or registering another + // failed owner. A retry cannot release its lease before that work returns. + while (_operations != 0) { + await _idle!.future; + } + Object? firstFailure; + final attempted = Function()>{}; + while (true) { + final remaining = _cleanup.where((item) => !attempted.contains(item)); + if (remaining.isEmpty) break; + final next = remaining.first; + attempted.add(next); + try { + await next(); + _cleanup.remove(next); + } catch (error) { + firstFailure ??= error; + } + while (_operations != 0) { + await _idle!.future; + } + } + if (firstFailure != null) throw firstFailure; + if (_cleanup.isEmpty) release(); + } + + /// Only call after every owned reader/native operation/store is terminal. + void release() { + if (_released) return; + if (_cleanup.isNotEmpty) throw StateError('Cleanup is still owned'); + if (_operations != 0) { + _releaseRequested = true; + return; + } + _released = true; + if (identical(_owner._active, this)) { + _owner._active = null; + _owner._changes.add(null); + } + } +} + +/// Cleanup remains actionable without erasing the original rejection/cancel. +class BackupProcessingCleanupFailure implements Exception { + const BackupProcessingCleanupFailure({ + this.originalError, + required this.cleanupError, + }); + final Object? originalError; + final Object cleanupError; +} diff --git a/lib/domain/entities/backup_recurring_time_review.dart b/lib/domain/entities/backup_recurring_time_review.dart new file mode 100644 index 00000000..776f990b --- /dev/null +++ b/lib/domain/entities/backup_recurring_time_review.dart @@ -0,0 +1,107 @@ +import 'backup_time_review.dart'; +import 'schedule_entity.dart'; +import 'schedule_time_resolution.dart'; +import 'time_correction_conflict.dart'; +import '../recurrence/recurrence_rule.dart'; +import '../recurrence/recurring_time_correction.dart'; + +/// A candidate-owned plan anchor. A segment with no materialized rows has no +/// invented schedule ID or ordinal, and this value is never persisted as a row. +class BackupRecurringTimeAnchor { + const BackupRecurringTimeAnchor({ + required this.segmentId, + required this.originalSlot, + this.scheduleId, + this.originalOrdinal, + }); + final String segmentId; + final DateTime originalSlot; + final String? scheduleId; + final int? originalOrdinal; +} + +class BackupRecurringTimeDraft { + BackupRecurringTimeDraft({ + required this.identity, + required this.revision, + required this.rulesIdentity, + required this.issueId, + required this.rule, + required this.endExplicitlyChosen, + this.replaceWholeSourceInterval = false, + Set excludedConflictSlots = const {}, + }) : excludedConflictSlots = Set.unmodifiable(excludedConflictSlots); + final String identity; + final int revision; + final String rulesIdentity; + final String issueId; + final RecurrenceRule rule; + final bool endExplicitlyChosen; + final bool replaceWholeSourceInterval; + final Set excludedConflictSlots; +} + +class BackupRecurringTimePlan { + BackupRecurringTimePlan({ + required this.draft, + required this.issue, + required this.anchor, + required this.originalRule, + required this.originalSchedule, + required this.originalFrom, + required this.originalBefore, + required this.closeAt, + required this.mapping, + required this.conflicts, + required Map unresolvedOverrides, + }) : unresolvedOverrides = Map.unmodifiable(unresolvedOverrides); + final BackupRecurringTimeDraft draft; + final BackupTimeReviewIssue issue; + final BackupRecurringTimeAnchor anchor; + final RecurrenceRule originalRule; + final ScheduleEntity originalSchedule; + final DateTime originalFrom; + final DateTime? originalBefore; + final DateTime closeAt; + final RecurringTimeCorrectionMapping mapping; + final TimeCorrectionConflictProof conflicts; + final Map unresolvedOverrides; + + bool get replacesWholeSourceInterval => draft.replaceWholeSourceInterval; +} + +class BackupRecurringTimeChoice { + BackupRecurringTimeChoice({ + required this.plan, + required Set confirmedDetachedIds, + required Set confirmedUnmatchedExclusions, + Set acknowledgedPossibleIds = const {}, + this.confirmedWholeSourceReplacement = false, + }) : confirmedDetachedIds = Set.unmodifiable(confirmedDetachedIds), + confirmedUnmatchedExclusions = Set.unmodifiable( + confirmedUnmatchedExclusions, + ), + acknowledgedPossibleIds = Set.unmodifiable(acknowledgedPossibleIds); + final BackupRecurringTimePlan plan; + final Set confirmedDetachedIds; + final Set confirmedUnmatchedExclusions; + final Set acknowledgedPossibleIds; + final bool confirmedWholeSourceReplacement; +} + +/// Only an authenticated candidate owner can implement these operations. A +/// successful choice changes its candidate revision, never the active store. +abstract interface class BackupRecurringTimeReviewPort { + Future recurrenceRule(String issueId); + Future reviewRecurrence( + BackupRecurringTimeDraft draft, + ); + Future chooseRecurrence(BackupRecurringTimeChoice choice); +} + +/// Closing at the materialized anchor would leave an unresolved future prefix. +/// The user must review and explicitly acknowledge replacing this one source +/// segment from its original generation boundary. +class BackupRecurringWholeReplacementRequired implements Exception { + const BackupRecurringWholeReplacementRequired(); +} diff --git a/lib/domain/entities/backup_restore_selection.dart b/lib/domain/entities/backup_restore_selection.dart new file mode 100644 index 00000000..2271a883 --- /dev/null +++ b/lib/domain/entities/backup_restore_selection.dart @@ -0,0 +1,5 @@ +/// An installation-owned selected backup resource, without activation authority. +/// Only a separately verified BackupRestoreInput may be passed to apply. +abstract class BackupRestoreSelection { + Future dispose() async {} +} diff --git a/lib/domain/entities/backup_time_review.dart b/lib/domain/entities/backup_time_review.dart new file mode 100644 index 00000000..3a3cbe35 --- /dev/null +++ b/lib/domain/entities/backup_time_review.dart @@ -0,0 +1,145 @@ +import 'time_correction_conflict.dart'; +import 'backup_restore_selection.dart'; +import 'civil_date_time.dart'; +import 'civil_time_occurrence.dart'; + +enum BackupTimeFieldKind { + scheduleOccurrence, + scheduleStartedAt, + scheduleFinishedAt, + templateCreatedAt, + templateUpdatedAt, + recurrenceRule, +} + +enum BackupTimeIssueReason { + unknownZone, + nonexistentCivil, + repeatedCivil, + changedOffset, + missingInstantOffset, + recurrenceOrder, +} + +/// Diagnostic state for an authenticated candidate, never a ready preview. +class BackupTimeReviewSummary { + const BackupTimeReviewSummary({ + required this.identity, + required this.revision, + required this.validationNowUtc, + required this.rulesIdentity, + required this.issueCount, + required this.independentStructureChecked, + }); + final String identity; + final int revision; + final DateTime validationNowUtc; + final String rulesIdentity; + final int issueCount; + + /// This does not claim that time-dependent relations or activation checks + /// have passed. Every edit requires a fresh complete validation pass. + final bool independentStructureChecked; +} + +class BackupTimeReviewIssue { + const BackupTimeReviewIssue({ + required this.id, + required this.kind, + required this.reason, + required this.fieldPath, + required this.name, + required this.originalLiteral, + required this.currentLiteral, + required this.civil, + this.zone, + this.selectedOffsetSeconds, + }); + final String id; + final BackupTimeFieldKind kind; + final BackupTimeIssueReason reason; + final String fieldPath; + final String name; + final String originalLiteral; + final String currentLiteral; + final CivilDateTime civil; + final String? zone; + final int? selectedOffsetSeconds; +} + +class BackupTimeReviewIssuePage { + BackupTimeReviewIssuePage( + List items, { + this.nextCursor, + }) : items = List.unmodifiable(items); + final List items; + final String? nextCursor; +} + +/// The owner binds the opaque issue ID to its own exact field path. Clients +/// cannot submit arbitrary JSON paths, IDs, graph edges, or field names. +class BackupTimeDraft { + const BackupTimeDraft({ + required this.identity, + required this.revision, + required this.rulesIdentity, + required this.issueId, + required this.civil, + required this.zone, + }); + final String identity; + final int revision; + final String rulesIdentity; + final String issueId; + final CivilDateTime civil; + final String zone; +} + +class BackupTimeFieldReview { + BackupTimeFieldReview({ + required this.draft, + required this.issue, + required List choices, + this.nextValidCivil, + this.previousInstantUtc, + Map conflictsByOffset = const {}, + }) : choices = List.unmodifiable(choices), + conflictsByOffset = Map.unmodifiable(conflictsByOffset); + final BackupTimeDraft draft; + final BackupTimeReviewIssue issue; + final List choices; + final CivilDateTime? nextValidCivil; + final DateTime? previousInstantUtc; + final Map conflictsByOffset; +} + +class BackupTimeChoice { + BackupTimeChoice({ + required this.review, + required this.offsetSeconds, + Set acknowledgedPossibleIds = const {}, + }) : acknowledgedPossibleIds = Set.unmodifiable(acknowledgedPossibleIds); + final BackupTimeFieldReview review; + final int offsetSeconds; + final Set acknowledgedPossibleIds; +} + +/// Same-lease authenticated time review. It owns no AppDatabase and exposes no +/// BackupRestorePreview. Revalidation may return another review state or a new +/// ready input; it cannot reuse an earlier ready input's identity. +abstract class BackupTimeReviewInput extends BackupRestoreSelection { + BackupTimeReviewSummary get summary; + Future issues({String? cursor}); + Future review(BackupTimeDraft draft); + Future choose(BackupTimeChoice choice); + Future revalidate(); +} + +class BackupTimeReviewStale implements Exception { + const BackupTimeReviewStale(); +} + +class BackupTimeChoiceConflict implements Exception { + const BackupTimeChoiceConflict(this.proof); + final TimeCorrectionConflictProof proof; +} diff --git a/lib/domain/entities/civil_date_time.dart b/lib/domain/entities/civil_date_time.dart new file mode 100644 index 00000000..9dca7901 --- /dev/null +++ b/lib/domain/entities/civil_date_time.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; + +/// Wall-clock fields, not an instant or the device's local time. +/// +/// The UTC DateTime is an adapter for calendar arithmetic only. Resolve the +/// named zone and selected offset before using this value on an instant axis. +final class CivilDateTime extends Equatable + implements Comparable { + CivilDateTime.fromFields(DateTime value) + : _carrier = DateTime.utc( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, + ) { + if (value.year < 1 || value.year > 9999) { + throw const FormatException('Civil year is out of range'); + } + } + + const CivilDateTime._(this._carrier); + final DateTime _carrier; + + factory CivilDateTime.parse(String value) { + final match = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:?\d{2})?$', + ).firstMatch(value); + if (match == null || match.end != value.length) { + throw const FormatException('Invalid civil time'); + } + // Historical civil wire values can carry a suffix. Validate its syntax + // without applying it to the wall fields, matching the portable decoder. + final suffix = match.group(8); + if (suffix != null && suffix != 'Z') { + final digits = suffix.substring(1).replaceAll(':', ''); + if (int.parse(digits.substring(0, 2)) > 23 || + int.parse(digits.substring(2)) > 59) { + throw const FormatException('Invalid civil offset suffix'); + } + } + final fields = [for (var i = 1; i <= 6; i++) int.parse(match.group(i)!)]; + final micros = int.parse((match.group(7) ?? '').padRight(6, '0')); + final carrier = DateTime.utc( + fields[0], + fields[1], + fields[2], + fields[3], + fields[4], + fields[5], + micros ~/ 1000, + micros % 1000, + ); + if (fields[0] < 1 || + fields[0] > 9999 || + carrier.year != fields[0] || + carrier.month != fields[1] || + carrier.day != fields[2] || + carrier.hour != fields[3] || + carrier.minute != fields[4] || + carrier.second != fields[5]) { + throw const FormatException('Invalid civil time'); + } + return CivilDateTime._(carrier); + } + + DateTime toUtcCarrier() => _carrier; + + String toCivilIso8601String() { + final encoded = _carrier.toIso8601String(); + return encoded.substring(0, encoded.length - 1); + } + + DateTime atOffset(int offsetSeconds) { + // abs(minInt64) wraps on native Dart, so compare before arithmetic. + if (offsetSeconds < -86400 || offsetSeconds > 86400) { + throw const FormatException('Occurrence offset is out of range'); + } + final instant = _carrier.subtract(Duration(seconds: offsetSeconds)); + if (instant.year < 1 || instant.year > 9999) { + throw const FormatException('Occurrence instant is out of range'); + } + return instant; + } + + @override + int compareTo(CivilDateTime other) => _carrier.compareTo(other._carrier); + + @override + List get props => [_carrier]; +} diff --git a/lib/domain/entities/civil_time_occurrence.dart b/lib/domain/entities/civil_time_occurrence.dart new file mode 100644 index 00000000..593d1908 --- /dev/null +++ b/lib/domain/entities/civil_time_occurrence.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +class CivilTimeOccurrence extends Equatable { + const CivilTimeOccurrence({ + required this.offsetSeconds, + required this.instantUtc, + }); + + final int offsetSeconds; + final DateTime instantUtc; + + @override + List get props => [offsetSeconds, instantUtc]; +} diff --git a/lib/domain/entities/default_preferences.dart b/lib/domain/entities/default_preferences.dart new file mode 100644 index 00000000..006fd247 --- /dev/null +++ b/lib/domain/entities/default_preferences.dart @@ -0,0 +1,84 @@ +import 'package:on_time_front/domain/entities/preparation_entity.dart'; + +/// A coherent edit starting point, scoped to one installation data generation. +class DefaultPreferencesSnapshot { + const DefaultPreferencesSnapshot({ + required this.preparation, + required this.spareTime, + required this.store, + required this.generation, + required this.revision, + }); + final PreparationEntity preparation; + final Duration spareTime; + final String store; + final int generation; + final int revision; +} + +class DefaultPreferencesSubmission { + DefaultPreferencesSubmission({ + required this.baseline, + required PreparationEntity preparation, + required this.spareTime, + }) : preparation = PreparationEntity( + preparationStepList: List.unmodifiable( + preparation.preparationStepList, + ), + ); + final DefaultPreferencesSnapshot baseline; + final PreparationEntity preparation; + final Duration spareTime; +} + +enum DefaultPreferencesAuthority { current, superseded, unavailable } + +enum DefaultPreferencesFailure { conflict, invalid, unavailable, failed } + +class DefaultPreferencesRejected implements Exception { + const DefaultPreferencesRejected(this.failure); + final DefaultPreferencesFailure failure; +} + +/// Commit remains true when a cache refresh or delivery projection is pending. +class DefaultPreferencesSaveReceipt { + const DefaultPreferencesSaveReceipt({ + required this.operation, + required this.store, + required this.generation, + required this.changed, + required this.revision, + this.reloadPending = false, + this.deliveryPending = false, + this.superseded = false, + this.authorityPending = false, + }); + final Object operation; + final String store; + final int generation; + final int revision; + final bool changed; + final bool reloadPending; + final bool deliveryPending; + final bool superseded; + final bool authorityPending; + bool get complete => + !reloadPending && !deliveryPending && !superseded && !authorityPending; + + DefaultPreferencesSaveReceipt withFollowUp({ + required bool reloadPending, + required bool deliveryPending, + bool superseded = false, + bool authorityPending = false, + }) => DefaultPreferencesSaveReceipt( + operation: operation, + store: store, + generation: generation, + changed: changed, + revision: revision, + reloadPending: reloadPending, + deliveryPending: deliveryPending, + superseded: superseded, + authorityPending: authorityPending, + ); +} diff --git a/lib/domain/entities/nearest_schedule_query.dart b/lib/domain/entities/nearest_schedule_query.dart new file mode 100644 index 00000000..b36a6d0c --- /dev/null +++ b/lib/domain/entities/nearest_schedule_query.dart @@ -0,0 +1,197 @@ +import 'package:equatable/equatable.dart'; +import 'schedule_time_resolution.dart'; +import 'schedule_with_preparation_entity.dart'; + +final class NearestQueryKey extends Equatable { + const NearestQueryKey({ + required this.generation, + required this.epoch, + required this.revision, + }); + final int generation; + final int epoch; + final int revision; + @override + List get props => [generation, epoch, revision]; +} + +final class NearestQueryAuthority extends Equatable { + const NearestQueryAuthority({ + required this.key, + required this.storeIncarnation, + required this.dataRevision, + required this.ruleDataIdentity, + required this.evaluatedAtUtc, + required this.verifiedAtUtc, + }); + final NearestQueryKey key; + final String storeIncarnation; + final int dataRevision; + final String ruleDataIdentity; + final DateTime evaluatedAtUtc; + final DateTime verifiedAtUtc; + @override + List get props => [ + key, + storeIncarnation, + dataRevision, + ruleDataIdentity, + evaluatedAtUtc, + verifiedAtUtc, + ]; +} + +final class NearestVerifiedSchedule extends Equatable { + const NearestVerifiedSchedule({ + required this.schedule, + required this.resolution, + required this.authority, + }); + final ScheduleWithPreparationEntity schedule; + final ScheduleTimeResolution resolution; + final NearestQueryAuthority authority; + @override + List get props => [schedule, resolution, authority]; +} + +final class NearestQueryProgress extends Equatable { + const NearestQueryProgress({ + required this.visitedCandidates, + required this.candidateBudget, + required this.provenSegments, + required this.totalSegments, + }); + final int visitedCandidates; + final int candidateBudget; + final int provenSegments; + final int totalSegments; + @override + List get props => [ + visitedCandidates, + candidateBudget, + provenSegments, + totalSegments, + ]; +} + +enum NearestQueryFailureReason { + storeReadFailed, + preparationReadFailed, + preparationMissing, + recurrenceMetadataInvalid, + currentStoreUnavailable, +} + +enum NearestQueryLimitReason { + interrupted, + cancelled, + searchBudgetExhausted, + representableRangeExhausted, +} + +enum NearestQueryIssueReason { + unknownTimeZone, + ambiguousOccurrence, + nonexistentCivilTime, + changedTimeRules, + uncertainHistoricalTime, + invalidTimeMetadata, +} + +final class NearestQueryIssue extends Equatable { + const NearestQueryIssue({ + required this.reason, + this.scheduleId, + this.segmentId, + }) : assert(scheduleId != null || segmentId != null); + final NearestQueryIssueReason reason; + final String? scheduleId; + final String? segmentId; + @override + List get props => [reason, scheduleId, segmentId]; +} + +sealed class NearestScheduleQuery extends Equatable { + const NearestScheduleQuery({ + required this.key, + this.stale, + this.issues = const [], + }); + final NearestQueryKey? key; + final NearestVerifiedSchedule? stale; + final List issues; + @override + List get props => [key, stale, issues]; +} + +final class NearestQueryIdle extends NearestScheduleQuery { + const NearestQueryIdle() : super(key: null); +} + +final class NearestQueryLoading extends NearestScheduleQuery { + const NearestQueryLoading({ + required NearestQueryKey key, + required this.progress, + super.stale, + super.issues, + }) : super(key: key); + final NearestQueryProgress progress; + @override + List get props => [...super.props, progress]; +} + +final class NearestQueryReady extends NearestScheduleQuery { + NearestQueryReady({ + required this.value, + List issues = const [], + }) : super(key: value.authority.key, issues: List.unmodifiable(issues)); + final NearestVerifiedSchedule value; + @override + List get props => [...super.props, value]; +} + +final class NearestQueryEmpty extends NearestScheduleQuery { + NearestQueryEmpty({ + required this.authority, + List issues = const [], + }) : super(key: authority.key, issues: List.unmodifiable(issues)); + final NearestQueryAuthority authority; + @override + List get props => [...super.props, authority]; +} + +final class NearestQueryError extends NearestScheduleQuery { + const NearestQueryError({ + required NearestQueryKey key, + required this.reason, + super.stale, + super.issues, + }) : super(key: key); + final NearestQueryFailureReason reason; + @override + List get props => [...super.props, reason]; +} + +final class NearestQueryLimited extends NearestScheduleQuery { + const NearestQueryLimited({ + required NearestQueryKey key, + required this.reason, + required this.progress, + required this.canContinue, + required this.canRetry, + super.stale, + super.issues, + }) : super(key: key); + final NearestQueryLimitReason reason; + final NearestQueryProgress progress; + final bool canContinue; + final bool canRetry; + @override + List get props => [ + ...super.props, + reason, + progress, + canContinue, + canRetry, + ]; +} diff --git a/lib/domain/entities/notification_route_payload.dart b/lib/domain/entities/notification_route_payload.dart index f54b3e3b..8251da90 100644 --- a/lib/domain/entities/notification_route_payload.dart +++ b/lib/domain/entities/notification_route_payload.dart @@ -11,10 +11,17 @@ Map minimalScheduleRoutePayload(Map source) { if (type != 'schedule_alarm' && type != 'schedule_notification') { return const {}; } + final incarnation = source['storeIncarnation']; + if (incarnation != null && + (incarnation is! String || + !RegExp(r'^[a-fA-F0-9-]{32,36}$').hasMatch(incarnation))) { + return const {}; + } return { 'type': type as String, 'scheduleId': id, - 'alarmLaunchPayloadVersion': '9', + if (incarnation is String) 'storeIncarnation': incarnation, + 'alarmLaunchPayloadVersion': '10', 'promptVariant': type == 'schedule_alarm' ? 'alarm' : 'notification', }; } diff --git a/lib/domain/entities/schedule_deletion.dart b/lib/domain/entities/schedule_deletion.dart new file mode 100644 index 00000000..36841989 --- /dev/null +++ b/lib/domain/entities/schedule_deletion.dart @@ -0,0 +1,63 @@ +import 'package:equatable/equatable.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; + +final class ScheduleDeletionTarget extends Equatable { + const ScheduleDeletionTarget({ + required this.id, + required this.incarnation, + required this.version, + }); + final String id; + final String incarnation; + final int version; + @override + List get props => [id, incarnation, version]; +} + +final class ScheduleDeletionIntent { + ScheduleDeletionIntent({ + required this.intentId, + required this.snapshot, + required this.scope, + required List targets, + }) : targets = List.unmodifiable(targets); + final String intentId; + final ScheduleEditSnapshot snapshot; + final RecurringEditScope scope; + final List targets; +} + +final class ScheduleDeletionCommit { + ScheduleDeletionCommit({ + required this.scheduleId, + required this.store, + required this.generation, + required Set removedIds, + required this.changed, + required this.alreadyAbsent, + }) : removedIds = Set.unmodifiable(removedIds); + final String scheduleId; + final String store; + final int generation; + final Set removedIds; + final bool changed; + + /// Current absence is not evidence that a historical request succeeded. + final bool alreadyAbsent; +} + +enum ScheduleDeletionFailure { conflict, protected, unavailable } + +final class ScheduleDeletionRejected implements Exception { + const ScheduleDeletionRejected(this.failure); + final ScheduleDeletionFailure failure; +} + +enum ScheduleDeletionCleanup { complete, pending, unconfirmed } + +final class ScheduleDeletionResult { + const ScheduleDeletionResult({required this.commit, required this.cleanup}); + final ScheduleDeletionCommit commit; + final ScheduleDeletionCleanup cleanup; +} diff --git a/lib/domain/entities/schedule_entity.dart b/lib/domain/entities/schedule_entity.dart index 627f4faf..d61df982 100644 --- a/lib/domain/entities/schedule_entity.dart +++ b/lib/domain/entities/schedule_entity.dart @@ -1,3 +1,5 @@ +import 'schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:equatable/equatable.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; @@ -25,6 +27,10 @@ class ScheduleEntity extends Equatable { final String? preparationTemplateName; final bool preparationTemplateDeleted; final bool preparationFrozen; + final bool requiresStartConfirmation; + + /// Derived from the owning segment; never portable or stored on the row. + final bool retainedRecurringReference; final bool scoreContributionRecorded; final PreparationEntity? customPreparations; @@ -55,6 +61,8 @@ class ScheduleEntity extends Equatable { this.preparationTemplateName, this.preparationTemplateDeleted = false, this.preparationFrozen = false, + this.requiresStartConfirmation = false, + this.retainedRecurringReference = false, this.scoreContributionRecorded = false, this.customPreparations, this.recurringSegmentId, @@ -73,20 +81,22 @@ class ScheduleEntity extends Equatable { /// to reinterpret the occurrence in the device's current time zone. DateTime get occurrenceInstantUtc { final offset = occurrenceOffsetSeconds; - if (offset == null) return scheduleTime.toUtc(); - final civilAsUtc = DateTime.utc( - scheduleTime.year, - scheduleTime.month, - scheduleTime.day, - scheduleTime.hour, - scheduleTime.minute, - scheduleTime.second, - scheduleTime.millisecond, - scheduleTime.microsecond, - ); - return civilAsUtc.subtract(Duration(seconds: offset)); + if (offset == null) { + throw const ScheduleTimeUnresolved( + ScheduleTimeResolutionStatus.historicalUncertain, + ); + } + return CivilDateTime.fromFields(scheduleTime).atOffset(offset); } + /// Canonical occurrence choice for in-memory identity. Merely reading this + /// value does not change the nullable stored offset. + int get resolvedOccurrenceOffsetSeconds => + occurrenceOffsetSeconds ?? + CivilDateTime.fromFields( + scheduleTime, + ).toUtcCarrier().difference(occurrenceInstantUtc).inSeconds; + ScheduleEntity copyWith({ String? id, PlaceEntity? place, @@ -110,6 +120,8 @@ class ScheduleEntity extends Equatable { String? preparationTemplateName, bool? preparationTemplateDeleted, bool? preparationFrozen, + bool? requiresStartConfirmation, + bool? retainedRecurringReference, bool? scoreContributionRecorded, PreparationEntity? customPreparations, String? recurringSegmentId, @@ -143,6 +155,11 @@ class ScheduleEntity extends Equatable { preparationTemplateDeleted: preparationTemplateDeleted ?? this.preparationTemplateDeleted, preparationFrozen: preparationFrozen ?? this.preparationFrozen, + requiresStartConfirmation: + requiresStartConfirmation ?? this.requiresStartConfirmation, + retainedRecurringReference: clearRecurring + ? false + : retainedRecurringReference ?? this.retainedRecurringReference, scoreContributionRecorded: scoreContributionRecorded ?? this.scoreContributionRecorded, customPreparations: customPreparations ?? this.customPreparations, @@ -174,7 +191,7 @@ class ScheduleEntity extends Equatable { scheduleName, timeZoneId, occurrenceOffsetSeconds, - scheduleTime, + CivilDateTime.fromFields(scheduleTime), moveTime, isChanged, isStarted, @@ -189,6 +206,8 @@ class ScheduleEntity extends Equatable { preparationTemplateName, preparationTemplateDeleted, preparationFrozen, + requiresStartConfirmation, + retainedRecurringReference, scoreContributionRecorded, customPreparations, recurringSegmentId, diff --git a/lib/domain/entities/schedule_time_correction.dart b/lib/domain/entities/schedule_time_correction.dart new file mode 100644 index 00000000..fd8ce9a6 --- /dev/null +++ b/lib/domain/entities/schedule_time_correction.dart @@ -0,0 +1,46 @@ +import 'civil_date_time.dart'; +import 'schedule_save.dart'; +import 'schedule_time_resolution.dart'; +import 'time_correction_conflict.dart'; + +/// Read-only evidence for a dedicated time review, never a write capability. +final class ScheduleTimeCorrectionReview { + const ScheduleTimeCorrectionReview({ + required this.snapshot, + required this.resolution, + required this.ruleIdentity, + required this.validationNowUtc, + this.blockedBy, + }); + + final ScheduleEditSnapshot snapshot; + final ScheduleTimeResolution resolution; + final String ruleIdentity; + final DateTime validationNowUtc; + final ScheduleSaveFailure? blockedBy; +} + +/// The only editable values in a single-occurrence time correction. +/// Names, places, preparations, history and recurrence ownership are absent. +final class ScheduleTimeCorrectionCommand { + ScheduleTimeCorrectionCommand({ + required this.review, + required this.civil, + required this.timeZoneId, + required this.offsetSeconds, + required this.mutationId, + Set acknowledgedUncertainIds = const {}, + }) : acknowledgedUncertainIds = Set.unmodifiable(acknowledgedUncertainIds); + + final ScheduleTimeCorrectionReview review; + final CivilDateTime civil; + final String timeZoneId; + final int offsetSeconds; + final String mutationId; + final Set acknowledgedUncertainIds; +} + +final class ScheduleTimeCorrectionConflict implements Exception { + const ScheduleTimeCorrectionConflict(this.proof); + final TimeCorrectionConflictProof proof; +} diff --git a/lib/domain/entities/schedule_time_resolution.dart b/lib/domain/entities/schedule_time_resolution.dart new file mode 100644 index 00000000..1ad65f45 --- /dev/null +++ b/lib/domain/entities/schedule_time_resolution.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; +import 'civil_time_occurrence.dart'; + +enum ScheduleTimeResolutionStatus { + resolved, + historicalUncertain, + unknownZone, + nonexistent, + ambiguous, + changed, + invalid, +} + +final class ScheduleTimeUnresolved implements Exception { + const ScheduleTimeUnresolved(this.status); + final ScheduleTimeResolutionStatus status; +} + +/// Interpretation only; start, notification and restore permission are separate. +final class ScheduleTimeResolution extends Equatable { + ScheduleTimeResolution({ + required this.status, + this.instantUtc, + this.proposedInstantUtc, + this.isHistorical = false, + List occurrences = const [], + }) : occurrences = List.unmodifiable(occurrences); + + final ScheduleTimeResolutionStatus status; + final DateTime? instantUtc; + final DateTime? proposedInstantUtc; + final bool isHistorical; + final List occurrences; + + @override + List get props => [ + status, + instantUtc, + proposedInstantUtc, + isHistorical, + occurrences, + ]; +} diff --git a/lib/domain/entities/schedule_with_preparation_entity.dart b/lib/domain/entities/schedule_with_preparation_entity.dart index 645e2e33..bbebee29 100644 --- a/lib/domain/entities/schedule_with_preparation_entity.dart +++ b/lib/domain/entities/schedule_with_preparation_entity.dart @@ -1,3 +1,5 @@ +import 'schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; @@ -6,6 +8,18 @@ import 'package:on_time_front/domain/entities/schedule_entity.dart'; class ScheduleWithPreparationEntity extends ScheduleEntity { final PreparationWithTimeEntity preparation; + /// Immutable interpretation for one read pass; not a database/start authority. + final ScheduleTimeResolution? timeResolution; + + @override + DateTime get occurrenceInstantUtc { + final resolved = timeResolution; + if (resolved == null) return super.occurrenceInstantUtc; + final instant = resolved.instantUtc; + if (instant == null) throw ScheduleTimeUnresolved(resolved.status); + return instant; + } + const ScheduleWithPreparationEntity({ required super.id, required super.place, @@ -27,6 +41,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { super.preparationTemplateName, super.preparationTemplateDeleted, super.preparationFrozen, + super.requiresStartConfirmation, + super.retainedRecurringReference, super.scoreContributionRecorded, super.customPreparations, super.recurringSegmentId, @@ -36,6 +52,7 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { super.preparationDefinitionId, required this.preparation, + this.timeResolution, }); ///Returns the total duration of the schedule including the moving time and the preparation time. @@ -49,13 +66,21 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { occurrenceInstantUtc.subtract(totalDuration); /// Fingerprint for validating whether cached timed-preparation is still valid. - String get timingIdentity => _identity('timing', [ - occurrenceInstantUtc.toIso8601String(), - timeZoneId, - occurrenceOffsetSeconds, - moveTime.inMilliseconds, - (scheduleSpareTime ?? Duration.zero).inMilliseconds, - ]); + String get timingIdentity { + final instant = occurrenceInstantUtc; + final canonicalOffset = + occurrenceOffsetSeconds ?? + CivilDateTime.fromFields( + scheduleTime, + ).toUtcCarrier().difference(instant).inSeconds; + return _identity('timing', [ + instant.toIso8601String(), + timeZoneId, + canonicalOffset, + moveTime.inMilliseconds, + (scheduleSpareTime ?? Duration.zero).inMilliseconds, + ]); + } String get preparationShapeIdentity => _identity('shape', [ for (final step in preparation.preparationStepList) @@ -80,7 +105,11 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { String get legacyCacheFingerprint { final spare = scheduleSpareTime ?? Duration.zero; final buffer = StringBuffer() - ..write(scheduleTime.toIso8601String()) + // The old SQLite civil converter constructed a local DateTime and its + // legacy fingerprint therefore serialized wall fields without a Z. Do + // not construct a device-local value here: a foreign DST gap could + // normalize those fields and accidentally validate a different run. + ..write(CivilDateTime.fromFields(scheduleTime).toCivilIso8601String()) ..write('|') ..write(timeZoneId) ..write('|') @@ -131,8 +160,9 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { static ScheduleWithPreparationEntity fromScheduleAndPreparationEntity( ScheduleEntity schedule, - PreparationWithTimeEntity preparation, - ) { + PreparationWithTimeEntity preparation, { + required ScheduleTimeResolution timeResolution, + }) { return ScheduleWithPreparationEntity( id: schedule.id, place: schedule.place, @@ -154,6 +184,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { preparationTemplateName: schedule.preparationTemplateName, preparationTemplateDeleted: schedule.preparationTemplateDeleted, preparationFrozen: schedule.preparationFrozen, + requiresStartConfirmation: schedule.requiresStartConfirmation, + retainedRecurringReference: schedule.retainedRecurringReference, scoreContributionRecorded: schedule.scoreContributionRecorded, customPreparations: schedule.customPreparations, recurringSegmentId: schedule.recurringSegmentId, @@ -163,6 +195,7 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { preparationDefinitionId: schedule.preparationDefinitionId, preparation: preparation, + timeResolution: timeResolution, ); } @@ -173,7 +206,7 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { scheduleName, timeZoneId, occurrenceOffsetSeconds, - scheduleTime, + CivilDateTime.fromFields(scheduleTime), moveTime, isChanged, isStarted, @@ -187,6 +220,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { preparationTemplateName, preparationTemplateDeleted, preparationFrozen, + requiresStartConfirmation, + retainedRecurringReference, scoreContributionRecorded, recurringSegmentId, recurringSlotKey, @@ -194,5 +229,6 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { recurringOverrides, preparationDefinitionId, preparation, + timeResolution, ]; } diff --git a/lib/domain/entities/time_correction_conflict.dart b/lib/domain/entities/time_correction_conflict.dart new file mode 100644 index 00000000..86186d49 --- /dev/null +++ b/lib/domain/entities/time_correction_conflict.dart @@ -0,0 +1,73 @@ +import 'schedule_entity.dart'; +import '../recurrence/recurrence_engine.dart'; +import '../recurrence/recurrence_rule.dart'; + +class TimeCorrectionBusyInterval { + const TimeCorrectionBusyInterval( + this.schedule, + this.preparationStartUtc, + this.instantUtc, + ); + final ScheduleEntity schedule; + final DateTime preparationStartUtc; + final DateTime instantUtc; +} + +class TimeCorrectionConflict { + const TimeCorrectionConflict({ + required this.slot, + required this.other, + this.otherSlotKey, + this.persistent = false, + }); + final RecurrenceSlot slot; + final TimeCorrectionBusyInterval other; + final String? otherSlotKey; + final bool persistent; +} + +class TimeCorrectionPossibleOverlap { + const TimeCorrectionPossibleOverlap({ + required this.id, + required this.name, + required this.reason, + this.originalCivil, + this.timeZoneId, + this.rule, + this.earliestPreparationUtc, + this.latestTargetUtc, + }); + final String id; + final String name; + final String reason; + final DateTime? originalCivil; + final String? timeZoneId; + final RecurrenceRule? rule; + final DateTime? earliestPreparationUtc; + final DateTime? latestTargetUtc; + bool mayOverlap(DateTime startUtc, DateTime endUtc) => + (earliestPreparationUtc == null || + !earliestPreparationUtc!.isAfter(endUtc)) && + (latestTargetUtc == null || !latestTargetUtc!.isBefore(startUtc)); +} + +class TimeCorrectionConflictProof { + TimeCorrectionConflictProof({ + required List conflicts, + required this.through, + required this.workUnits, + this.earliestPreparationUtc, + this.latestTargetUtc, + List possibleOverlaps = const [], + List proposedSlots = const [], + }) : conflicts = List.unmodifiable(conflicts), + possibleOverlaps = List.unmodifiable(possibleOverlaps), + proposedSlots = List.unmodifiable(proposedSlots); + final List conflicts; + final List possibleOverlaps; + final List proposedSlots; + final DateTime through; + final int workUnits; + final DateTime? earliestPreparationUtc; + final DateTime? latestTargetUtc; +} diff --git a/lib/domain/ports/backup_file_export_port.dart b/lib/domain/ports/backup_file_export_port.dart new file mode 100644 index 00000000..2952bb0c --- /dev/null +++ b/lib/domain/ports/backup_file_export_port.dart @@ -0,0 +1,29 @@ +import 'dart:typed_data'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +enum BackupFileExportReceipt { saved, cancelled } + +abstract interface class BackupFileExportPort { + Future exportStream({ + required Stream> encrypted, + required String suggestedName, + BackupProcessingLease? lease, + }); +} + +/// Explicit memory adapter for small callers/tests; product exports use streams. +extension MemoryBackupExport on BackupFileExportPort { + Future export({ + required Uint8List encryptedBytes, + required String suggestedName, + }) => exportStream( + encrypted: Stream.value(encryptedBytes), + suggestedName: suggestedName, + ); +} + +final class BackupFileExportFailure implements Exception { + const BackupFileExportFailure(); + @override + String toString() => 'Backup file saving was not confirmed.'; +} diff --git a/lib/domain/ports/backup_file_import_port.dart b/lib/domain/ports/backup_file_import_port.dart new file mode 100644 index 00000000..fe6e7787 --- /dev/null +++ b/lib/domain/ports/backup_file_import_port.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +abstract interface class BackupFileImportPort { + Future select({BackupProcessingLease? lease}); +} + +/// No provider URI, path, name or persistent access grant crosses this port. +abstract interface class BackupImportSource { + int? get declaredLength; + Stream openRead(); + Future close(); +} diff --git a/lib/domain/ports/backup_time_review_port.dart b/lib/domain/ports/backup_time_review_port.dart new file mode 100644 index 00000000..d1734643 --- /dev/null +++ b/lib/domain/ports/backup_time_review_port.dart @@ -0,0 +1,7 @@ +import '../entities/backup_restore_selection.dart'; + +/// Optional authenticated selection capability shared by normal restore and +/// pre-DI recovery. A selected review has no activation authority. +abstract interface class BackupTimeReviewPort { + Future selectForRestore(String password); +} diff --git a/lib/domain/ports/recovery_preclaim_cleanup_port.dart b/lib/domain/ports/recovery_preclaim_cleanup_port.dart new file mode 100644 index 00000000..985c6a0e --- /dev/null +++ b/lib/domain/ports/recovery_preclaim_cleanup_port.dart @@ -0,0 +1,4 @@ +/// A failed pre-confirmation cleanup has no restore activation authority. +abstract interface class RecoveryPreclaimCleanupPort { + Future retryPreclaimCleanup(int generation); +} diff --git a/lib/domain/ports/recovery_restore_port.dart b/lib/domain/ports/recovery_restore_port.dart new file mode 100644 index 00000000..a6048221 --- /dev/null +++ b/lib/domain/ports/recovery_restore_port.dart @@ -0,0 +1,9 @@ +import 'package:on_time_front/domain/entities/backup_operation.dart'; + +/// Pre-DI recovery needs neither active DB revision nor export/freshness. +abstract interface class RecoveryRestorePort { + Future preview(String password); + Future activate(BackupRestoreInput input); + Future resume(); + Future abort(); +} diff --git a/lib/domain/recurrence/recurrence_engine.dart b/lib/domain/recurrence/recurrence_engine.dart index c8d5db0d..a34808d1 100644 --- a/lib/domain/recurrence/recurrence_engine.dart +++ b/lib/domain/recurrence/recurrence_engine.dart @@ -63,30 +63,57 @@ class RecurrenceEngine { int? limit, int candidateBudget = 200000, }) { - if (leadTime.isNegative) throw ArgumentError.value(leadTime, 'leadTime'); if (candidateBudget < 1 || (limit != null && limit < 1)) { throw ArgumentError('Expansion budgets must be positive.'); } - // Initialize the existing bundled time-zone database before validating the - // zone; recurring rules must not silently fall back to UTC for bad input. + final slots = []; + final skipped = []; + var visited = 0; + for (final step in scan( + rule, + through: through, + from: from, + preparationNotBeforeUtc: preparationNotBeforeUtc, + leadTime: leadTime, + excludedKeys: excludedKeys, + )) { + if (++visited > candidateBudget) throw const RecurrenceSearchLimit(); + if (step.skipped != null) skipped.add(step.skipped!); + if (step.slot != null) slots.add(step.slot!); + if (limit != null && slots.length == limit) break; + } + return RecurrenceExpansion( + slots: List.unmodifiable(slots), + skipped: List.unmodifiable(skipped), + ); + } + + /// One yield per visited candidate, including skipped/excluded/history slots. + /// Keeping this iterator in memory lets bounded consumers continue without + /// restarting count. [expand] and nearest queries share these exact rules. + Iterable scan( + RecurrenceRule rule, { + required DateTime through, + DateTime? from, + DateTime? preparationNotBeforeUtc, + Duration leadTime = Duration.zero, + Set excludedKeys = const {}, + }) sync* { + if (leadTime.isNegative) throw ArgumentError.value(leadTime, 'leadTime'); CivilTimeResolver.resolve(rule.start, 'UTC'); tz.getLocation(rule.timeZoneId); var end = RecurrenceRule.civilDate(through); if (rule.until != null && rule.until!.isBefore(end)) end = rule.until!; final lower = from == null ? rule.start : RecurrenceRule.civilTime(from); - final slots = []; - final skipped = []; var counted = 0; - var candidates = 0; - for (final date in _dates(rule, end)) { - if (++candidates > candidateBudget) throw const RecurrenceSearchLimit(); if (date.missing) { - if (!date.value.isBefore(RecurrenceRule.civilDate(lower))) { - skipped.add( - RecurrenceSkip(date.value, RecurrenceSkipReason.nonexistentDate), - ); - } + yield RecurrenceScanStep( + date.value, + skipped: !date.value.isBefore(RecurrenceRule.civilDate(lower)) + ? RecurrenceSkip(date.value, RecurrenceSkipReason.nonexistentDate) + : null, + ); continue; } final civil = DateTime.utc( @@ -99,14 +126,18 @@ class RecurrenceEngine { rule.start.millisecond, rule.start.microsecond, ); - if (civil.isBefore(rule.start)) continue; + if (civil.isBefore(rule.start)) { + yield RecurrenceScanStep(civil); + continue; + } final resolved = CivilTimeResolver.resolve(civil, rule.timeZoneId); if (resolved.isEmpty) { - if (!civil.isBefore(lower)) { - skipped.add( - RecurrenceSkip(civil, RecurrenceSkipReason.nonexistentTime), - ); - } + yield RecurrenceScanStep( + civil, + skipped: !civil.isBefore(lower) + ? RecurrenceSkip(civil, RecurrenceSkipReason.nonexistentTime) + : null, + ); continue; } if (resolved.length > 1 && rule.repeatedTime == null) { @@ -120,33 +151,32 @@ class RecurrenceEngine { actual.instantUtc .subtract(leadTime) .isBefore(preparationNotBeforeUtc.toUtc())) { - if (!civil.isBefore(lower)) { - skipped.add( - RecurrenceSkip(civil, RecurrenceSkipReason.preparationPassed), - ); - } + yield RecurrenceScanStep( + civil, + skipped: !civil.isBefore(lower) + ? RecurrenceSkip(civil, RecurrenceSkipReason.preparationPassed) + : null, + ); continue; } counted++; - if (!civil.isBefore(lower) && - !excludedKeys.contains(civil.toIso8601String())) { - slots.add( - RecurrenceSlot( - civilTime: civil, - instantUtc: actual.instantUtc, - offsetSeconds: actual.offsetSeconds, - ordinal: counted, - ), - ); - } - if (counted == rule.count || (limit != null && slots.length == limit)) { - break; - } + final slot = + !civil.isBefore(lower) && + !excludedKeys.contains(civil.toIso8601String()) + ? RecurrenceSlot( + civilTime: civil, + instantUtc: actual.instantUtc, + offsetSeconds: actual.offsetSeconds, + ordinal: counted, + ) + : null; + yield RecurrenceScanStep( + civil, + slot: slot, + countComplete: counted == rule.count, + ); + if (counted == rule.count) break; } - return RecurrenceExpansion( - slots: List.unmodifiable(slots), - skipped: List.unmodifiable(skipped), - ); } Iterable<_CandidateDate> _dates(RecurrenceRule rule, DateTime end) sync* { @@ -219,3 +249,17 @@ class _CandidateDate { final DateTime value; final bool missing; } + +/// Frontier is conservative even for nonexistent calendar dates. +final class RecurrenceScanStep { + const RecurrenceScanStep( + this.frontierCivil, { + this.slot, + this.skipped, + this.countComplete = false, + }); + final DateTime frontierCivil; + final RecurrenceSlot? slot; + final RecurrenceSkip? skipped; + final bool countComplete; +} diff --git a/lib/domain/recurrence/recurrence_reference_policy.dart b/lib/domain/recurrence/recurrence_reference_policy.dart new file mode 100644 index 00000000..02095196 --- /dev/null +++ b/lib/domain/recurrence/recurrence_reference_policy.dart @@ -0,0 +1,53 @@ +import '../entities/civil_date_time.dart'; +import '../entities/schedule_entity.dart'; +import 'recurrence_rule.dart'; + +/// A closed generation interval can still own history and deletion tombstones. +/// Retaining such a reference never grants another start or delivery authority. +abstract final class RecurrenceReferencePolicy { + /// A proof from stored civil bounds, independent of a zone registry. + /// Count alone is deliberately insufficient: missing historical rules may + /// change which calendar candidates consumed that count. + static bool hasNoFutureGeneration({ + required RecurrenceRule rule, + required DateTime fromSlot, + required DateTime? beforeSlot, + required DateTime nowUtc, + }) { + final from = RecurrenceRule.civilTime(fromSlot); + DateTime? exclusiveEnd = beforeSlot == null + ? null + : RecurrenceRule.civilTime(beforeSlot); + if (exclusiveEnd != null && !exclusiveEnd.isAfter(from)) return true; + final until = rule.until; + if (until != null) { + final end = DateTime.utc(until.year, until.month, until.day + 1); + if (exclusiveEnd == null || end.isBefore(exclusiveEnd)) { + exclusiveEnd = end; + } + } + if (exclusiveEnd == null) return false; + // Every supported offset is in [-24h,+24h]. Even the latest possible + // instant before this exclusive civil boundary must already be past. + return !exclusiveEnd.add(const Duration(hours: 24)).isAfter(nowUtc.toUtc()); + } + + static bool isClosedTail(String slot, String? beforeSlot) => + beforeSlot != null && + CivilDateTime.parse(slot).compareTo(CivilDateTime.parse(beforeSlot)) >= 0; + + static bool hasProtectedFacts(ScheduleEntity value) => + value.isStarted || + value.preparationFrozen || + value.doneStatus != ScheduleDoneStatus.notEnded; + + static bool isProvablyPast(ScheduleEntity value, DateTime nowUtc) { + final civil = CivilDateTime.fromFields(value.scheduleTime); + final offset = value.occurrenceOffsetSeconds; + // Missing offset must not acquire the device zone or an invented instant. + final latest = offset == null + ? civil.toUtcCarrier().add(const Duration(hours: 24)) + : civil.atOffset(offset); + return latest.isBefore(nowUtc.toUtc()); + } +} diff --git a/lib/domain/recurrence/recurrence_rule.dart b/lib/domain/recurrence/recurrence_rule.dart index 8ab47d58..bc367dfd 100644 --- a/lib/domain/recurrence/recurrence_rule.dart +++ b/lib/domain/recurrence/recurrence_rule.dart @@ -91,6 +91,21 @@ class RecurrenceRule extends Equatable { repeatedTime: repeatedTime ?? this.repeatedTime, ); + RecurrenceRule withTimeZone(String zone) => RecurrenceRule( + frequency: frequency, + start: start, + timeZoneId: zone, + interval: interval, + weekdays: weekdays, + monthly: monthly, + monthDay: monthDay, + ordinal: ordinal, + monthWeekday: monthWeekday, + until: until, + count: count, + repeatedTime: repeatedTime, + ); + static DateTime civilDate(DateTime value) => DateTime.utc(value.year, value.month, value.day); diff --git a/lib/domain/recurrence/recurring_time_correction.dart b/lib/domain/recurrence/recurring_time_correction.dart new file mode 100644 index 00000000..2994a2bb --- /dev/null +++ b/lib/domain/recurrence/recurring_time_correction.dart @@ -0,0 +1,113 @@ +import '../entities/schedule_entity.dart'; +import '../entities/time_correction_conflict.dart'; +import '../entities/schedule_time_correction.dart'; +import '../entities/schedule_time_resolution.dart'; +import 'recurrence_engine.dart'; +import 'recurrence_rule.dart'; + +final class TimeCorrectionExclusion { + const TimeCorrectionExclusion(this.segmentId, this.slot, this.ordinal); + final String segmentId; + final String slot; + final int ordinal; +} + +final class TimeCorrectionRowMapping { + const TimeCorrectionRowMapping(this.original, this.slot); + final ScheduleEntity original; + + /// Null means explicit standalone preservation, never deletion. + final RecurrenceSlot? slot; + bool get detached => slot == null; +} + +final class TimeCorrectionExclusionMapping { + const TimeCorrectionExclusionMapping(this.original, this.slot); + final TimeCorrectionExclusion original; + + /// The original tombstone survives whether or not a new slot corresponds. + final RecurrenceSlot? slot; +} + +final class RecurringTimeCorrectionMapping { + RecurringTimeCorrectionMapping({ + required this.rule, + required this.automaticallyRetainedCount, + required List rows, + required List protectedRows, + required List exclusions, + required List protectedSlots, + required this.workUnits, + required this.firstSlot, + List conflictExclusions = const [], + }) : rows = List.unmodifiable(rows), + protectedRows = List.unmodifiable(protectedRows), + exclusions = List.unmodifiable(exclusions), + protectedSlots = List.unmodifiable(protectedSlots), + conflictExclusions = List.unmodifiable(conflictExclusions); + + final RecurrenceRule rule; + final bool automaticallyRetainedCount; + final List rows; + final List protectedRows; + final List exclusions; + final List protectedSlots; + final int workUnits; + final RecurrenceSlot? firstSlot; + final List conflictExclusions; +} + +final class RecurringTimeCorrectionCountRequired implements Exception { + const RecurringTimeCorrectionCountRequired(); +} + +final class RecurringTimeCorrectionRequest { + RecurringTimeCorrectionRequest({ + required this.anchorReview, + required this.rule, + required this.endExplicitlyChosen, + Set excludedConflictSlots = const {}, + }) : excludedConflictSlots = Set.unmodifiable(excludedConflictSlots); + final ScheduleTimeCorrectionReview anchorReview; + final RecurrenceRule rule; + final bool endExplicitlyChosen; + final Set excludedConflictSlots; +} + +final class RecurringTimeCorrectionReview { + RecurringTimeCorrectionReview({ + required this.request, + required this.mapping, + required this.sourceDigest, + this.conflictProof, + required Map unresolvedOverrides, + }) : unresolvedOverrides = Map.unmodifiable(unresolvedOverrides); + final RecurringTimeCorrectionRequest request; + final RecurringTimeCorrectionMapping mapping; + final String sourceDigest; + final TimeCorrectionConflictProof? conflictProof; + + /// Stable IDs needing their own explicit occurrence selection before commit. + final Map unresolvedOverrides; +} + +final class RecurringTimeCorrectionCommand { + RecurringTimeCorrectionCommand({ + required this.review, + required this.mutationId, + required Set confirmedDetachedIds, + required Set confirmedUnmatchedExclusions, + Set acknowledgedUncertainIds = const {}, + }) : acknowledgedUncertainIds = Set.unmodifiable(acknowledgedUncertainIds), + confirmedDetachedIds = Set.unmodifiable(confirmedDetachedIds), + confirmedUnmatchedExclusions = Set.unmodifiable( + confirmedUnmatchedExclusions, + ); + final RecurringTimeCorrectionReview review; + final String mutationId; + final Set confirmedDetachedIds; + final Set acknowledgedUncertainIds; + + /// Exact source segment ID and civil slot, generated by the review service. + final Set confirmedUnmatchedExclusions; +} diff --git a/lib/domain/repositories/default_preferences_repository.dart b/lib/domain/repositories/default_preferences_repository.dart new file mode 100644 index 00000000..46fdd7fc --- /dev/null +++ b/lib/domain/repositories/default_preferences_repository.dart @@ -0,0 +1,13 @@ +import 'package:on_time_front/domain/entities/default_preferences.dart'; + +abstract interface class DefaultPreferencesRepository { + Future read(); + Future save( + DefaultPreferencesSubmission input, + ); + Future authority( + DefaultPreferencesSaveReceipt receipt, + ); + int get generation; + bool isGenerationCurrent(int generation); +} diff --git a/lib/domain/repositories/nearest_schedule_query_repository.dart b/lib/domain/repositories/nearest_schedule_query_repository.dart new file mode 100644 index 00000000..41456300 --- /dev/null +++ b/lib/domain/repositories/nearest_schedule_query_repository.dart @@ -0,0 +1,18 @@ +import '../entities/nearest_schedule_query.dart'; +import '../entities/schedule_with_preparation_entity.dart'; + +/// A session is installation-owned, in-memory and never serializable. +abstract interface class NearestScheduleQuerySession { + Future advance({required int candidateBudget}); + void cancel(); +} + +abstract interface class NearestScheduleQueryRepository { + Stream get changes; + Future open(NearestQueryKey key); + Future readActive(); +} + +final class NearestQueryInvalidated implements Exception { + const NearestQueryInvalidated(); +} diff --git a/lib/domain/repositories/recurring_schedule_repository.dart b/lib/domain/repositories/recurring_schedule_repository.dart index f7784dde..bea9d139 100644 --- a/lib/domain/repositories/recurring_schedule_repository.dart +++ b/lib/domain/repositories/recurring_schedule_repository.dart @@ -1,6 +1,7 @@ import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; abstract interface class RecurringScheduleRepository { @@ -49,4 +50,9 @@ abstract interface class RecurringScheduleRepository { int? perSeriesLimit, }); Future getPreparation(String definitionId); + ScheduleEntity candidateFor(RecurringSegment segment, RecurrenceSlot slot); + Future materializeCandidate( + RecurringSegment segment, + RecurrenceSlot slot, + ); } diff --git a/lib/domain/repositories/recurring_time_correction_repository.dart b/lib/domain/repositories/recurring_time_correction_repository.dart new file mode 100644 index 00000000..2347ddf1 --- /dev/null +++ b/lib/domain/repositories/recurring_time_correction_repository.dart @@ -0,0 +1,10 @@ +import '../entities/schedule_save.dart'; +import '../recurrence/recurring_time_correction.dart'; + +abstract interface class RecurringTimeCorrectionRepository { + Future review( + RecurringTimeCorrectionRequest request, + ); + Future confirm(RecurringTimeCorrectionCommand command); + bool isCurrent(ScheduleSaveReceipt receipt); +} diff --git a/lib/domain/repositories/schedule_aggregate_repository.dart b/lib/domain/repositories/schedule_aggregate_repository.dart index 32f4b122..145ce484 100644 --- a/lib/domain/repositories/schedule_aggregate_repository.dart +++ b/lib/domain/repositories/schedule_aggregate_repository.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/schedule_save.dart'; import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; @@ -12,4 +14,10 @@ abstract interface class ScheduleAggregateRepository { required bool editing, }); bool isCurrent(ScheduleSaveReceipt receipt); + Future readForDeletion( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }); + Future delete(ScheduleDeletionIntent intent); + Future isDeletionCurrent(ScheduleDeletionCommit commit); } diff --git a/lib/domain/repositories/schedule_repository.dart b/lib/domain/repositories/schedule_repository.dart index 020d4c13..441ffd47 100644 --- a/lib/domain/repositories/schedule_repository.dart +++ b/lib/domain/repositories/schedule_repository.dart @@ -39,7 +39,12 @@ abstract interface class ScheduleRepository { Future deleteSchedule(ScheduleEntity schedule); /// Start a schedule preparation session - Future startSchedule(String scheduleId, {DateTime? startedAt}); + Future startSchedule( + String scheduleId, { + DateTime? startedAt, + bool Function()? isCurrent, + String? expectedFingerprint, + }); /// Finish a schedule with lateness time Future finishSchedule(String scheduleId, int latenessTime); diff --git a/lib/domain/repositories/schedule_time_correction_repository.dart b/lib/domain/repositories/schedule_time_correction_repository.dart new file mode 100644 index 00000000..50e3087a --- /dev/null +++ b/lib/domain/repositories/schedule_time_correction_repository.dart @@ -0,0 +1,12 @@ +import '../entities/schedule_save.dart'; +import '../entities/schedule_time_correction.dart'; +import '../entities/time_correction_conflict.dart'; + +abstract interface class ScheduleTimeCorrectionRepository { + Future review(String scheduleId); + Future reviewChoice( + ScheduleTimeCorrectionCommand command, + ); + Future confirm(ScheduleTimeCorrectionCommand command); + bool isCurrent(ScheduleSaveReceipt receipt); +} diff --git a/lib/domain/use-cases/default_preferences_workflow.dart b/lib/domain/use-cases/default_preferences_workflow.dart new file mode 100644 index 00000000..ad43bb13 --- /dev/null +++ b/lib/domain/use-cases/default_preferences_workflow.dart @@ -0,0 +1,110 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; +import 'package:on_time_front/domain/repositories/default_preferences_repository.dart'; +import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; + +@Injectable() +class DefaultPreferencesWorkflow { + DefaultPreferencesWorkflow(this.repository, this.loadUser, this.effects); + final DefaultPreferencesRepository repository; + final LoadUserUseCase loadUser; + final ScheduleMutationAlarmEffectsCoordinator effects; + final _saves = Expando>(); + final _saved = Expando(); + final _followUps = Expando>(); + final _latest = Expando(); + + int get generation => repository.generation; + Future read() => repository.read(); + bool isGenerationCurrent(int generation) => + repository.isGenerationCurrent(generation); + + Future save( + DefaultPreferencesSubmission input, + ) { + final pending = _saves[input]; + if (pending != null) return pending; + final completed = _saved[input]; + if (completed != null) return retry(completed); + final future = _save(input); + _saves[input] = future; + future.then( + (_) { + _saves[input] = null; + }, + onError: (Object _, StackTrace __) { + _saves[input] = null; + }, + ); + return future; + } + + Future _save( + DefaultPreferencesSubmission input, + ) async { + final receipt = await repository.save(input); + // Record commit before any follow-up: reusing this command cannot write twice. + _saved[input] = receipt; + return retry(receipt); + } + + Future retry( + DefaultPreferencesSaveReceipt receipt, + ) { + final pending = _followUps[receipt.operation]; + if (pending != null) return pending; + final latest = _latest[receipt.operation] ?? receipt; + final future = _retry(latest); + _followUps[receipt.operation] = future; + future.then( + (value) { + _latest[receipt.operation] = value; + _followUps[receipt.operation] = null; + }, + onError: (Object _, StackTrace __) { + _followUps[receipt.operation] = null; + }, + ); + return future; + } + + Future _retry( + DefaultPreferencesSaveReceipt receipt, + ) async { + var reload = receipt.reloadPending; + var delivery = receipt.deliveryPending; + DefaultPreferencesSaveReceipt result( + DefaultPreferencesAuthority authority, + ) => receipt.withFollowUp( + reloadPending: reload, + deliveryPending: delivery, + superseded: authority == DefaultPreferencesAuthority.superseded, + authorityPending: authority == DefaultPreferencesAuthority.unavailable, + ); + var authority = await repository.authority(receipt); + if (authority != DefaultPreferencesAuthority.current) { + return result(authority); + } + if (reload) { + try { + await loadUser(); + reload = false; + } catch (_) { + /* Commit remains durable. */ + } + } + authority = await repository.authority(receipt); + if (authority != DefaultPreferencesAuthority.current) { + return result(authority); + } + if (delivery) { + try { + delivery = !await effects.afterCommit(); + } catch (_) { + /* Retry only the current-data projection. */ + } + } + return result(await repository.authority(receipt)); + } +} diff --git a/lib/domain/use-cases/delete_schedule_use_case.dart b/lib/domain/use-cases/delete_schedule_use_case.dart index 2652798e..1145edee 100644 --- a/lib/domain/use-cases/delete_schedule_use_case.dart +++ b/lib/domain/use-cases/delete_schedule_use_case.dart @@ -1,23 +1,244 @@ +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/domain/entities/schedule_not_found.dart'; import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_registration_cleanup.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/repositories/schedule_repository.dart'; -import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_aggregate_repository.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; @Injectable() class DeleteScheduleUseCase { - final ScheduleRepository _scheduleRepository; - final ScheduleMutationAlarmEffectsCoordinator _alarmEffectsCoordinator; - DeleteScheduleUseCase( - this._scheduleRepository, - this._alarmEffectsCoordinator, - ); + this._repository, + AlarmRegistryRepository registry, + AlarmSchedulerService scheduler, + FallbackAlarmNotificationService fallback, + this._sessions, + this._reconcile, { + @ignoreParam AlarmOperationCoordinator? operations, + }) : _operations = operations ?? AlarmOperationCoordinator.shared, + _registry = registry, + _cleanup = AlarmRegistrationCleanup( + registry, + scheduler, + fallback, + operations ?? AlarmOperationCoordinator.shared, + ); - Future call(ScheduleEntity schedule) async { - await _scheduleRepository.deleteSchedule(schedule); - await _alarmEffectsCoordinator( - operation: ScheduleMutationAlarmOperation.deleted, - scheduleId: schedule.id, + final ScheduleAggregateRepository _repository; + final AlarmRegistryRepository _registry; + final SchedulePreparationSessionUseCase _sessions; + final ReconcileAlarmsUseCase _reconcile; + final AlarmOperationCoordinator _operations; + final AlarmRegistrationCleanup _cleanup; + final _pending = Expando>(); + final _committed = Expando(); + final _retries = Expando>(); + + int get currentGeneration => _operations.generation; + + bool isCurrentGeneration(int generation) => + _operations.canSchedule && _operations.generation == generation; + + Future prepare( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) async { + final lease = _operations.capture(); + final intent = await _repository.readForDeletion(id, scope: scope); + lease.check(); + await _sessions.assertDeletionAllowed(intent.snapshot, lease: lease); + lease.check(); + return intent; + } + + Future confirm( + ScheduleDeletionIntent intent, { + void Function(ScheduleDeletionCommit)? onCommitted, + }) { + final flight = _pending[intent]; + if (flight != null) return flight; + final previous = _committed[intent]; + final future = previous == null + ? _confirm(intent, onCommitted) + : retryCleanup(previous); + _pending[intent] = future; + future.then( + (_) => _pending[intent] = null, + onError: (Object _, StackTrace __) => _pending[intent] = null, + ); + return future; + } + + Future _confirm( + ScheduleDeletionIntent intent, + void Function(ScheduleDeletionCommit)? onCommitted, + ) async { + final lease = _operations.capture(); + if (lease.generation != intent.snapshot.baseline.generation) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + final result = await _operations.run(lease, () async { + // Re-read every target before preserving delivery ownership. A concurrent + // durable write is checked again by the transaction before it commits. + for (final target in intent.targets) { + ScheduleDeletionIntent current; + try { + current = await _repository.readForDeletion(target.id); + } on ScheduleNotFound { + if (target.id != intent.snapshot.schedule.id) { + throw const ScheduleDeletionRejected( + ScheduleDeletionFailure.conflict, + ); + } + final commit = await _repository.delete(intent); + _committed[intent] = commit; + lease.check(); + if (onCommitted != null) { + try { + onCommitted(commit); + } catch (_) { + /* Observer only. */ + } + } + return _finishCleanup(commit, lease); + } + lease.check(); + if (current.snapshot.baseline.store != intent.snapshot.baseline.store || + current.snapshot.baseline.incarnation != target.incarnation || + current.snapshot.baseline.version != target.version) { + throw const ScheduleDeletionRejected( + ScheduleDeletionFailure.conflict, + ); + } + await _sessions.assertDeletionAllowed(current.snapshot, lease: lease); + lease.check(); + } + final stored = await _operations.loadRecords(_registry); + lease.check(); + final ids = intent.targets.map((target) => target.id).toSet(); + final targets = stored + .where((record) => ids.contains(record.scheduleId)) + .map(AlarmOperationCoordinator.ownershipOnly) + .toList(); + // This write verifies its read-back. Even an empty projection must establish + // journal availability/unknown ownership before identifiers can disappear. + final journal = await _operations.journal.read(); + lease.check(); + for (final record in targets) { + journal.ownership.putIfAbsent( + alarmOwnershipKey(record), + () => AlarmOwnership(record, pending: false), + ); + } + await _operations.journal.save(journal); + lease.check(); + final commit = await _repository.delete(intent); + _committed[intent] = commit; + lease.check(); + if (onCommitted != null) { + try { + onCommitted(commit); + } catch (_) { + // An observer cannot undo a committed deletion or skip OS cleanup. + } + } + return _finishCleanup(commit, lease); + }); + if (lease.isCurrent) requestAlarmReconciliation(_reconcile); + return result; + } + + Future retryCleanup(ScheduleDeletionCommit commit) { + final running = _retries[commit]; + if (running != null) return running; + final future = _retryCleanup(commit); + _retries[commit] = future; + future.then( + (_) => _retries[commit] = null, + onError: (Object _, StackTrace __) => _retries[commit] = null, + ); + return future; + } + + Future _retryCleanup( + ScheduleDeletionCommit commit, + ) async { + final lease = _operations.capture(); + if (lease.generation != commit.generation) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + final result = await _operations.run( + lease, + () => _finishCleanup(commit, lease), ); + if (lease.isCurrent) requestAlarmReconciliation(_reconcile); + return result; + } + + Future _finishCleanup( + ScheduleDeletionCommit commit, + AlarmOperationLease lease, + ) async { + if (!await _repository.isDeletionCurrent(commit)) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + lease.check(); + var state = ScheduleDeletionCleanup.complete; + for (final id in {...commit.removedIds, commit.scheduleId}) { + try { + await _sessions.clearDeletedStateUnderOwner( + id, + lease: lease, + isCurrent: () => _repository.isDeletionCurrent(commit), + ); + lease.check(); + } on AlarmOperationInvalidated { + rethrow; + } catch (_) { + state = ScheduleDeletionCleanup.unconfirmed; + } + // A same-ID replacement must not inherit the old cleanup authority. + if (!await _repository.isDeletionCurrent(commit)) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + lease.check(); + try { + final report = await _cleanup.cancelMatchingReport( + scheduleId: id, + isCurrent: () => lease.isCurrent, + isStillOwned: () => _repository.isDeletionCurrent(commit), + ); + lease.check(); + if (!report.isComplete && state == ScheduleDeletionCleanup.complete) { + state = report.unknownOwnership + ? ScheduleDeletionCleanup.unconfirmed + : ScheduleDeletionCleanup.pending; + } + } on AlarmOperationInvalidated { + rethrow; + } catch (_) { + state = ScheduleDeletionCleanup.unconfirmed; + } + } + return ScheduleDeletionResult(commit: commit, cleanup: state); + } + + /// Compatibility entry for already-confirmed callers. It still uses the safe + /// workflow and refuses to silently replace the caller's displayed snapshot. + Future call(ScheduleEntity schedule) async { + final intent = await prepare(schedule.id); + if (intent.snapshot.schedule != schedule) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + await confirm(intent); } } diff --git a/lib/domain/use-cases/finish_schedule_use_case.dart b/lib/domain/use-cases/finish_schedule_use_case.dart index 04dee17d..893984f3 100644 --- a/lib/domain/use-cases/finish_schedule_use_case.dart +++ b/lib/domain/use-cases/finish_schedule_use_case.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/domain/repositories/schedule_repository.dart'; @@ -6,20 +7,27 @@ import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_c @Injectable() class FinishScheduleUseCase { final ScheduleRepository _scheduleRepository; + final AlarmOperationCoordinator _operations; final ScheduleMutationAlarmEffectsCoordinator _alarmEffectsCoordinator; FinishScheduleUseCase( this._scheduleRepository, - this._alarmEffectsCoordinator, - ); + this._alarmEffectsCoordinator, { + @ignoreParam AlarmOperationCoordinator? operations, + }) : _operations = operations ?? AlarmOperationCoordinator.shared; Future call(String scheduleId, int latenessTime) async { - try { - await _scheduleRepository.startSchedule(scheduleId); - } on ScheduleStartRejected { - // Repeated finish remains idempotent; start itself rejects ended runs. - } - await _scheduleRepository.finishSchedule(scheduleId, latenessTime); + final owner = _operations; + final lease = owner.capture(); + await owner.run(lease, () async { + try { + await _scheduleRepository.startSchedule(scheduleId); + } on ScheduleStartRejected { + // Repeated finish remains idempotent; start itself rejects ended runs. + } + await _scheduleRepository.finishSchedule(scheduleId, latenessTime); + }); + lease.check(); await _alarmEffectsCoordinator( operation: ScheduleMutationAlarmOperation.finished, scheduleId: scheduleId, diff --git a/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart b/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart index aaf0cd80..9fdb468f 100644 --- a/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart +++ b/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'dart:async'; import 'package:injectable/injectable.dart'; @@ -20,7 +23,7 @@ class GetAdjacentSchedulesWithPreparationUseCase { /// Gets both the previous and next closest schedules relative to the given DateTime with preparation data from the stream. /// - /// [selectedDateTime] - The date and time to search from + /// [selectedDateTime] - The selected UTC occurrence instant to search from /// [currentScheduleId] - Optional ID of the current schedule being edited (to exclude it) /// [startDate] - Start date for the search range /// [endDate] - End date for the search range @@ -34,10 +37,26 @@ class GetAdjacentSchedulesWithPreparationUseCase { }) async { try { // Get schedules from the stream - final schedules = await _getSchedulesByDateUseCase( - startDate, - endDate, + final loaded = await _getSchedulesByDateUseCase( + CivilDateTime.fromFields( + startDate, + ).toUtcCarrier().subtract(const Duration(days: 2)), + CivilDateTime.fromFields( + endDate, + ).toUtcCarrier().add(const Duration(days: 2)), ).first; + final now = DateTime.now().toUtc(); + final schedules = []; + final instants = {}; + final resolutions = {}; + for (final value in loaded) { + if (value.retainedRecurringReference) continue; + final time = ScheduleTimeResolver.resolve(value, nowUtc: now); + if (time.instantUtc == null) continue; + schedules.add(value); + instants[value.id] = time.instantUtc!; + resolutions[value.id] = time; + } AppLogger.debug( 'Schedule filtering selectedDateTime=$selectedDateTime ' @@ -51,8 +70,10 @@ class GetAdjacentSchedulesWithPreparationUseCase { // because we want to warn about overlaps even with completed schedules final filteredSchedules = schedules.where((schedule) { final isNotCurrent = schedule.id != currentScheduleId; - final isAfterSelected = schedule.scheduleTime.isAfter(selectedDateTime); - final timeComparison = schedule.scheduleTime.compareTo( + final isAfterSelected = !instants[schedule.id]!.isBefore( + selectedDateTime.toUtc(), + ); + final timeComparison = instants[schedule.id]!.compareTo( selectedDateTime, ); @@ -70,7 +91,7 @@ class GetAdjacentSchedulesWithPreparationUseCase { // Filter schedules before selectedDateTime for previous schedule final previousSchedules = schedules.where((schedule) { final isNotCurrent = schedule.id != currentScheduleId; - final isBeforeSelected = schedule.scheduleTime.isBefore( + final isBeforeSelected = instants[schedule.id]!.isBefore( selectedDateTime, ); @@ -116,6 +137,7 @@ class GetAdjacentSchedulesWithPreparationUseCase { return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, preparation, + timeResolution: resolutions[schedule.id]!, ); } catch (e) { // If preparation is not in stream, return null @@ -132,7 +154,7 @@ class GetAdjacentSchedulesWithPreparationUseCase { if (filteredSchedules.isNotEmpty) { // Sort by scheduleTime and get the first one (closest) filteredSchedules.sort( - (a, b) => a.scheduleTime.compareTo(b.scheduleTime), + (a, b) => instants[a.id]!.compareTo(instants[b.id]!), ); nextSchedule = await getScheduleWithPreparation( filteredSchedules.first, @@ -144,7 +166,7 @@ class GetAdjacentSchedulesWithPreparationUseCase { if (previousSchedules.isNotEmpty) { // Sort by scheduleTime descending and get the first one (closest before) previousSchedules.sort( - (a, b) => b.scheduleTime.compareTo(a.scheduleTime), + (a, b) => instants[b.id]!.compareTo(instants[a.id]!), ); previousSchedule = await getScheduleWithPreparation( previousSchedules.first, diff --git a/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart b/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart index 21874b91..c1be66a5 100644 --- a/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart +++ b/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart @@ -1,62 +1,239 @@ import 'dart:async'; - import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; -import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_schedules_for_week_use_case.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; @Injectable() class GetNearestUpcomingScheduleUseCase { - final GetSchedulesByDateUseCase _getScheduleByDateUseCase; - final LoadPreparationByScheduleIdUseCase _loadPreparationByScheduleIdUseCase; - final GetPreparationByScheduleIdUseCase _getPreparationByScheduleIdUseCase; - final LoadSchedulesForWeekUseCase _loadSchedulesForWeekUseCase; - GetNearestUpcomingScheduleUseCase( - this._getScheduleByDateUseCase, - this._loadPreparationByScheduleIdUseCase, - this._getPreparationByScheduleIdUseCase, - this._loadSchedulesForWeekUseCase, - ); + this._repository, { + @ignoreParam DateTime Function()? now, + @ignoreParam int chunkSize = 1024, + }) : _now = now ?? DateTime.now, + _chunkSize = chunkSize; + final NearestScheduleQueryRepository _repository; + final DateTime Function() _now; + final int _chunkSize; - Stream call() async* { - final DateTime now = DateTime.now(); + Future readActive() => + _repository.readActive(); - unawaited(_loadSchedulesForWeekUseCase(now).catchError((_) {})); + Stream call({required NearestQueryKey key}) { + late StreamController output; + StreamSubscription? changes; + NearestScheduleQuerySession? session; + NearestQueryKey? sessionRequest; + var completedSession = false; + Timer? boundary; + var closed = false; + var paused = false; + Completer? resumed; + Future waitForResume() async { + while (paused && !closed) { + resumed ??= Completer(); + await resumed!.future; + } + } - final upcomingScheduleStream = _getScheduleByDateUseCase( - now, - now.add(const Duration(days: 2)), - ); - await for (final upcomingSchedule in upcomingScheduleStream) { - if (upcomingSchedule.isNotEmpty) { + var owner = 0; + var revision = key.revision; + var dirty = false; + var running = false; + NearestVerifiedSchedule? previous; + bool current(int value) => + !closed && + owner == value && + key.generation == LocalDataOperationGate.shared.generation && + LocalDataOperationGate.shared.isAvailable; + Future drain({bool revalidateCompleted = false}) async { + if (running || closed) { + dirty = true; + return; + } + running = true; + var reuseCompleted = + revalidateCompleted && session != null && sessionRequest != null; + do { + await waitForResume(); + if (closed) break; + final reuse = reuseCompleted && !dirty; + reuseCompleted = false; + dirty = false; + final run = ++owner; + final request = reuse + ? sessionRequest! + : NearestQueryKey( + generation: key.generation, + epoch: key.epoch, + revision: revision++, + ); + boundary?.cancel(); + if (!reuse) { + session?.cancel(); + session = null; + sessionRequest = null; + } + completedSession = false; + if (!current(run)) break; + output.add( + NearestQueryLoading( + key: request, + stale: previous, + progress: const NearestQueryProgress( + visitedCandidates: 0, + candidateBudget: 200000, + provenSegments: 0, + totalSegments: 0, + ), + ), + ); try { - final schedule = upcomingSchedule.firstWhere( - (s) => s.doneStatus == ScheduleDoneStatus.notEnded, - orElse: () => throw Exception('No upcoming schedule found'), - ); - - await _loadPreparationByScheduleIdUseCase(schedule.id); - final preparation = await _getPreparationByScheduleIdUseCase( - schedule.id, - ); - final scheduleWithPreparation = - ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( - schedule, - PreparationWithTimeEntity.fromPreparation(preparation), + final opened = reuse ? session! : await _repository.open(request); + if (!current(run)) { + opened.cancel(); + break; + } + session = opened; + sessionRequest = request; + while (current(run) && !dirty) { + await waitForResume(); + if (!current(run) || dirty) break; + final result = await opened.advance(candidateBudget: _chunkSize); + await waitForResume(); + if (!current(run) || dirty) break; + if (result is NearestQueryLimited && result.canContinue) { + output.add( + NearestQueryLoading( + key: request, + stale: previous, + progress: result.progress, + issues: result.issues, + ), + ); + await Future.delayed(Duration.zero); + continue; + } + if (result is NearestQueryReady) { + previous = result.value; + final delay = + result.value.resolution.instantUtc!.difference( + _now().toUtc(), + ) + + const Duration(microseconds: 1); + boundary = Timer( + delay < const Duration(milliseconds: 1) + ? const Duration(milliseconds: 1) + : delay, + () { + unawaited(drain()); + }, ); - yield scheduleWithPreparation; - } catch (e) { - yield null; - continue; + } + if (result is NearestQueryEmpty) previous = null; + completedSession = true; + output.add( + result is NearestQueryError + ? NearestQueryError( + key: request, + reason: result.reason, + stale: previous, + issues: result.issues, + ) + : result is NearestQueryLimited + ? NearestQueryLimited( + key: request, + reason: result.reason, + progress: result.progress, + canContinue: result.canContinue, + canRetry: result.canRetry, + stale: previous, + issues: result.issues, + ) + : result, + ); + break; + } + } on NearestQueryInvalidated { + if (current(run)) dirty = true; + } catch (_) { + await waitForResume(); + if (current(run) && !dirty) { + completedSession = true; + output.add( + NearestQueryError( + key: request, + reason: NearestQueryFailureReason.storeReadFailed, + stale: previous, + ), + ); + } } - } else { - yield null; - } + } while (dirty && !closed); + running = false; } + + output = StreamController( + onListen: () { + changes = _repository.changes.listen( + (_) { + unawaited(drain()); + }, + onError: (Object error, StackTrace stack) { + owner++; + session?.cancel(); + session = null; + sessionRequest = null; + completedSession = true; + boundary?.cancel(); + if (!closed) { + output.add( + NearestQueryError( + key: NearestQueryKey( + generation: key.generation, + epoch: key.epoch, + revision: revision++, + ), + reason: NearestQueryFailureReason.storeReadFailed, + stale: previous, + ), + ); + } + }, + ); + unawaited(drain()); + }, + onPause: () { + paused = true; + }, + onResume: () { + paused = false; + resumed?.complete(); + resumed = null; + // A terminal receipt may already be queued in the consumer when it + // pauses. Revalidate the same completed cursor instead of replaying a + // cached receipt or resetting its candidate budget on Continue. + if (completedSession) { + if (running) { + // A watch failure may have retired an in-flight operation. Its + // eventual completion must hand off to a fresh current read. + dirty = true; + } else { + unawaited(drain(revalidateCompleted: true)); + } + } + }, + onCancel: () async { + closed = true; + resumed?.complete(); + resumed = null; + owner++; + boundary?.cancel(); + session?.cancel(); + await changes?.cancel(); + }, + ); + return output.stream; } } diff --git a/lib/domain/use-cases/load_schedule_form_draft_use_case.dart b/lib/domain/use-cases/load_schedule_form_draft_use_case.dart index 815d2533..b17130b9 100644 --- a/lib/domain/use-cases/load_schedule_form_draft_use_case.dart +++ b/lib/domain/use-cases/load_schedule_form_draft_use_case.dart @@ -114,6 +114,13 @@ class LoadScheduleFormDraftUseCase { final defaultPreparation = snapshot?.preparation ?? await _getDefaultPreparationUseCase(); + String detectedZone; + try { + detectedZone = await _timeZoneId(); + } catch (_) { + // Keep the draft accessible so the user can explicitly select a zone. + detectedZone = ''; + } return ScheduleFormDraft( baseline: snapshot?.baseline, id: _newId(), @@ -123,7 +130,7 @@ class LoadScheduleFormDraftUseCase { scheduleTime: initialDate == null ? null : _initialScheduleTime(initialDate, _now()), - timeZoneId: await _timeZoneId(), + timeZoneId: detectedZone, occurrenceOffsetSeconds: null, moveTime: null, preparationChanged: false, @@ -164,16 +171,16 @@ class LoadScheduleFormDraftUseCase { } DateTime _initialScheduleTime(DateTime initialDate, DateTime now) { - final selectedDate = DateTime( + final selectedDate = DateTime.utc( initialDate.year, initialDate.month, initialDate.day, ); - final today = DateTime(now.year, now.month, now.day); + final today = DateTime.utc(now.year, now.month, now.day); final initialTime = selectedDate == today ? now.add(const Duration(minutes: 1)) : now; - return DateTime( + return DateTime.utc( initialDate.year, initialDate.month, initialDate.day, diff --git a/lib/domain/use-cases/local_data_workflows.dart b/lib/domain/use-cases/local_data_workflows.dart index 3c3cff1f..beb81548 100644 --- a/lib/domain/use-cases/local_data_workflows.dart +++ b/lib/domain/use-cases/local_data_workflows.dart @@ -1,3 +1,5 @@ +import '../entities/backup_restore_selection.dart'; +import '../ports/backup_time_review_port.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/domain/entities/backup_operation.dart'; import 'package:on_time_front/domain/entities/local_reset_result.dart'; @@ -20,6 +22,13 @@ class BackupWorkflow { Future preview(String password) => _backup.preview(password); + Future selectForRestore(String password) { + final port = _backup; + return port is BackupTimeReviewPort + ? (port as BackupTimeReviewPort).selectForRestore(password) + : port.preview(password); + } + Future restore(BackupRestoreInput input) { final running = _pending[input]; if (running != null) return running; @@ -35,16 +44,22 @@ class BackupWorkflow { final int committedGeneration; try { committedGeneration = await _backup.apply(input); + } on BackupRestoreCommitUncertain catch (error) { + final receipt = BackupRestoreReceipt.uncertain( + generation: error.generation, + ); + _completed[input] = receipt; + return receipt; } on DataOperationException catch (error) { return BackupRestoreReceipt( - committed: false, + disposition: BackupCommitDisposition.notCommitted, generation: error.generation ?? startedGeneration, failure: error.failure, followUpPending: error.followUpPending, ); } catch (_) { return BackupRestoreReceipt( - committed: false, + disposition: BackupCommitDisposition.notCommitted, generation: startedGeneration, failure: DataOperationFailure.failed, ); @@ -53,7 +68,7 @@ class BackupWorkflow { // Record commit before waiting for external work. A thrown delivery result // can never cause the caller to replace durable data again. final pending = BackupRestoreReceipt( - committed: true, + disposition: BackupCommitDisposition.committed, generation: committedGeneration, followUpPending: true, ); @@ -66,16 +81,22 @@ class BackupWorkflow { } Future retryFollowUp(BackupRestoreReceipt receipt) { - if (!receipt.followUpPending) return Future.value(receipt); + if (!receipt.followUpPending || + receipt.disposition == BackupCommitDisposition.undetermined) { + return Future.value(receipt); + } return _retries[receipt] ??= _followUp( receipt, ).whenComplete(() => _retries[receipt] = null); } Future _followUp(BackupRestoreReceipt receipt) async { + if (receipt.disposition == BackupCommitDisposition.undetermined) { + return receipt; + } if (receipt.generation != generation) { return BackupRestoreReceipt( - committed: receipt.committed, + disposition: receipt.disposition, generation: receipt.generation, followUpPending: true, failure: DataOperationFailure.stalePreview, @@ -88,7 +109,7 @@ class BackupWorkflow { /* Safe partial receipt. */ } final result = BackupRestoreReceipt( - committed: receipt.committed, + disposition: receipt.disposition, generation: receipt.generation, followUpPending: !complete || receipt.generation != generation, ); diff --git a/lib/domain/use-cases/reconcile_alarms_use_case.dart b/lib/domain/use-cases/reconcile_alarms_use_case.dart index 99e433ee..33335230 100644 --- a/lib/domain/use-cases/reconcile_alarms_use_case.dart +++ b/lib/domain/use-cases/reconcile_alarms_use_case.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:async'; import 'dart:ui' as ui; @@ -130,6 +132,7 @@ class ReconcileAlarmsUseCase { Future _run(AlarmOperationLease lease) async { lease.check(); + final contentPermit = _operations.captureContentPermit(); final now = _nowProvider(); final scheduleWindowStart = now; final scheduleWindowEnd = DateTime(now.year + 50, 1, 1); @@ -251,13 +254,20 @@ class ReconcileAlarmsUseCase { ); return result; } + String? deviceZone; + try { + deviceZone = await _timeZoneProvider(); + } catch (_) { + // Display-zone discovery does not revoke resolved schedule instants or + // the authority to clean up requests already owned by this installation. + } final allDesiredRecords = _desiredRecords( schedules: schedules, now: now, alarmCoverageEnd: alarmCoverageEnd, alarmOffset: settings.alarmOffset, detailedNotificationContent: settings.detailedNotificationContent, - currentTimeZoneId: await _timeZoneProvider(), + currentTimeZoneId: deviceZone, languageCode: _languageCodeProvider(), ); final desiredRecords = allDesiredRecords @@ -351,8 +361,24 @@ class ReconcileAlarmsUseCase { } } + bool preservePrivate(ScheduledAlarmRecord record) { + final desired = desiredByScheduleId[record.scheduleId]; + return desired != null && + desired.deliveryContent.detailed && + !_operations.allowsDetailed(contentPermit) && + record.hasCurrentContent && + !record.deliveryContent.detailed && + _timingAndRoutingMatch(record, desired) && + record.provider == deliveryPolicy.activeProvider && + _recordProviderMatchesCapabilities(record, capabilities) && + timingMatches(record) && + deliveryPolicy.canDeliver && + !_fallbackIdentityConflict(record, fallbackObservation); + } + lease.check(); final staleRecords = existingRecords.where((record) { + if (preservePrivate(record)) return false; final desired = desiredByScheduleId[record.scheduleId]; return !identical(record, existingByScheduleId[record.scheduleId]) || !deliveryPolicy.canDeliver || @@ -367,7 +393,28 @@ class ReconcileAlarmsUseCase { '$_logTag staleRecords=${staleRecords.length} ' 'stale=${_recordSummary(staleRecords)}', ); - final failedCancellations = await _cancelRecords(staleRecords); + final failedCancellations = []; + final deferredPrivate = []; + for (final record in List.of(staleRecords)) { + if (preservePrivate(record)) { + deferredPrivate.add(record); + continue; + } + try { + failedCancellations.addAll( + await _cleanup.cancelRecords([ + record, + ], isCurrent: () => lease.isCurrent && !preservePrivate(record)), + ); + } on AlarmOperationInvalidated { + lease.check(); + if (!preservePrivate(record)) rethrow; + // A privacy request arrived while ownership was being persisted. Keep + // the existing ID; no caller may mistake this for confirmed delivery. + deferredPrivate.add(record); + } + } + staleRecords.removeWhere(deferredPrivate.contains); lease.check(); final blockedScheduleIds = failedCancellations .map((record) => record.scheduleId) @@ -401,6 +448,19 @@ class ReconcileAlarmsUseCase { if (blockedScheduleIds.contains(desired.scheduleId)) continue; if (!desired.alarmTime.isAfter(_nowProvider())) continue; final existing = existingByScheduleId[desired.scheduleId]; + if (desired.deliveryContent.detailed && + !_operations.allowsDetailed(contentPermit)) { + if (existing != null && !staleRecords.contains(existing)) { + retainedRecords.add(existing); + } + failures.add( + AlarmFailure( + scheduleId: desired.scheduleId, + reason: AlarmFailureReason.contentDeferred, + ), + ); + continue; + } if (existing != null && !staleRecords.contains(existing) && existing.provider == deliveryPolicy.activeProvider && @@ -438,6 +498,7 @@ class ReconcileAlarmsUseCase { : desired; final scheduled = await _scheduleRecord( reapplied, + contentPermit: contentPermit, lease: lease, capabilities: capabilities, deliveryPolicy: deliveryPolicy, @@ -550,10 +611,22 @@ class ReconcileAlarmsUseCase { required DateTime alarmCoverageEnd, required Duration alarmOffset, required bool detailedNotificationContent, - required String currentTimeZoneId, + required String? currentTimeZoneId, required String languageCode, }) { - final records = schedules + final resolved = []; + for (final schedule in schedules) { + final time = ScheduleTimeResolver.resolve(schedule, nowUtc: now); + if (time.instantUtc == null) continue; + resolved.add( + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + schedule.preparation, + timeResolution: time, + ), + ); + } + final records = resolved .where( (schedule) => _isDesired(schedule, now, alarmCoverageEnd, alarmOffset), @@ -566,6 +639,7 @@ class ReconcileAlarmsUseCase { detailedNotificationContent: detailedNotificationContent, currentTimeZoneId: currentTimeZoneId, languageCode: languageCode, + storeIncarnation: RestoreRuntimeIdentity.shared.storeIncarnation, ), ) .toList(); @@ -581,7 +655,12 @@ class ReconcileAlarmsUseCase { ) { if (!isAlarmEligibleSchedule(schedule) || schedule.isStarted) return false; if (schedule.id.isEmpty) return false; - final alarmTime = computeAlarmTime(schedule, offset: alarmOffset); + final time = ScheduleTimeResolver.resolve(schedule, nowUtc: now); + final instant = time.instantUtc; + if (instant == null) return false; + final alarmTime = instant + .subtract(schedule.totalDuration) + .subtract(alarmOffset); return alarmTime.isAfter(now) && (alarmTime.isBefore(alarmCoverageEnd) || alarmTime.isAtSameMomentAs(alarmCoverageEnd)); @@ -611,11 +690,20 @@ class ReconcileAlarmsUseCase { Future<_ScheduleAttempt> _scheduleRecord( ScheduledAlarmRecord desired, { + required AlarmContentPermit contentPermit, required AlarmOperationLease lease, required AlarmSchedulerCapabilities capabilities, required AlarmDeliveryPolicy deliveryPolicy, required AlarmPermissionState fallbackPermission, }) async { + bool held() => + desired.deliveryContent.detailed && + !_operations.allowsDetailed(contentPermit); + if (held()) { + return const _ScheduleAttempt( + failureReason: AlarmFailureReason.contentDeferred, + ); + } if (deliveryPolicy.mode == AlarmDeliveryMode.nativeAlarm) { final record = desired.copyWith( provider: capabilities.nativeAlarmProvider, @@ -629,6 +717,12 @@ class ReconcileAlarmsUseCase { failureReason: AlarmFailureReason.scheduleInvalid, ); } + if (held()) { + return _ScheduleAttempt( + pendingRecord: AlarmOperationCoordinator.ownershipOnly(record), + failureReason: AlarmFailureReason.contentDeferred, + ); + } await _schedulerService.scheduleNativeAlarm(record); lease.check(); return _ScheduleAttempt(record: record); @@ -662,6 +756,11 @@ class ReconcileAlarmsUseCase { if (capabilities.fallbackProvider == AlarmProvider.localNotification && fallbackPermission == AlarmPermissionState.granted) { + if (held()) { + return const _ScheduleAttempt( + failureReason: AlarmFailureReason.contentDeferred, + ); + } final record = desired.copyWith( provider: AlarmProvider.localNotification, ); @@ -674,6 +773,12 @@ class ReconcileAlarmsUseCase { failureReason: AlarmFailureReason.scheduleInvalid, ); } + if (held()) { + return _ScheduleAttempt( + pendingRecord: AlarmOperationCoordinator.ownershipOnly(record), + failureReason: AlarmFailureReason.contentDeferred, + ); + } final timing = await _fallbackNotificationService.scheduleFallbackAlarm( record, ); @@ -775,15 +880,23 @@ class ReconcileAlarmsUseCase { return existing.hasCurrentContent && existing.contentVersion == desired.contentVersion && existing.contentDigest == desired.contentDigest && - existing.scheduleFingerprint == desired.scheduleFingerprint && - existing.payload['alarmLaunchPayloadVersion'] == - desired.payload['alarmLaunchPayloadVersion'] && - existing.alarmTime.isAtSameMomentAs(desired.alarmTime) && - existing.preparationStartTime.isAtSameMomentAs( - desired.preparationStartTime, - ); + _timingAndRoutingMatch(existing, desired); } + bool _timingAndRoutingMatch( + ScheduledAlarmRecord existing, + ScheduledAlarmRecord desired, + ) => + existing.scheduleFingerprint == desired.scheduleFingerprint && + existing.payload['storeIncarnation'] == + desired.payload['storeIncarnation'] && + existing.payload['alarmLaunchPayloadVersion'] == + desired.payload['alarmLaunchPayloadVersion'] && + existing.alarmTime.isAtSameMomentAs(desired.alarmTime) && + existing.preparationStartTime.isAtSameMomentAs( + desired.preparationStartTime, + ); + bool _recordProviderMatchesCapabilities( ScheduledAlarmRecord record, AlarmSchedulerCapabilities capabilities, diff --git a/lib/domain/use-cases/recurring_schedules_use_case.dart b/lib/domain/use-cases/recurring_schedules_use_case.dart index de23e056..6b6edeeb 100644 --- a/lib/domain/use-cases/recurring_schedules_use_case.dart +++ b/lib/domain/use-cases/recurring_schedules_use_case.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; @@ -14,10 +17,15 @@ class RecurringScheduleSummary { @Injectable() class RecurringSchedulesUseCase { - RecurringSchedulesUseCase(this._recurring, this._schedules, this._alarms); + RecurringSchedulesUseCase( + this._recurring, + this._schedules, + ScheduleMutationAlarmEffectsCoordinator alarms, + this.deletions, + ); + final DeleteScheduleUseCase deletions; final RecurringScheduleRepository _recurring; final ScheduleRepository _schedules; - final ScheduleMutationAlarmEffectsCoordinator _alarms; Future getSegment(String id) => _recurring.getSegment(id); @@ -27,6 +35,10 @@ class RecurringSchedulesUseCase { now.subtract(const Duration(days: 2)), null, ); + final instants = { + for (final value in occurrences) + value.id: ScheduleTimeResolver.resolve(value, nowUtc: now).instantUtc, + }; final segments = await _recurring.getSegments(); final result = []; for (final series in segments.map((s) => s.seriesId).toSet()) { @@ -45,13 +57,10 @@ class RecurringSchedulesUseCase { !s.isStarted && !s.preparationFrozen && s.doneStatus == ScheduleDoneStatus.notEnded && - s.occurrenceInstantUtc.isAfter(now.toUtc()), + (instants[s.id]?.isAfter(now.toUtc()) ?? false), ) .toList() - ..sort( - (a, b) => - a.occurrenceInstantUtc.compareTo(b.occurrenceInstantUtc), - ); + ..sort((a, b) => instants[a.id]!.compareTo(instants[b.id]!)); result.add( RecurringScheduleSummary( own.last, @@ -78,14 +87,14 @@ class RecurringSchedulesUseCase { return _recurring.review(value.schedule, value.preparation, rule); } - Future delete( + Future delete( ScheduleEntity occurrence, RecurringEditScope scope, ) async { - await _recurring.delete(occurrence, scope); - await _alarms( - operation: ScheduleMutationAlarmOperation.deleted, - scheduleId: occurrence.id, - ); + final intent = await deletions.prepare(occurrence.id, scope: scope); + if (intent.snapshot.schedule != occurrence) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.conflict); + } + return deletions.confirm(intent); } } diff --git a/lib/domain/use-cases/recurring_time_correction_workflow.dart b/lib/domain/use-cases/recurring_time_correction_workflow.dart new file mode 100644 index 00000000..9bbb6a58 --- /dev/null +++ b/lib/domain/use-cases/recurring_time_correction_workflow.dart @@ -0,0 +1,67 @@ +import 'package:injectable/injectable.dart'; +import '../entities/schedule_save.dart'; +import '../recurrence/recurring_time_correction.dart'; +import '../repositories/recurring_time_correction_repository.dart'; +import 'schedule_mutation_alarm_effects_coordinator.dart'; + +@Injectable() +class RecurringTimeCorrectionWorkflow { + RecurringTimeCorrectionWorkflow(this.repository, this.effects); + final RecurringTimeCorrectionRepository repository; + final ScheduleMutationAlarmEffectsCoordinator effects; + final _pending = + >{}; + final _delivery = Expando>(); + + Future confirm(RecurringTimeCorrectionCommand command) { + final existing = _pending[command]; + if (existing != null) return existing; + final future = _confirm(command); + _pending[command] = future; + future.then( + (_) { + _pending.remove(command); + }, + onError: (Object _, StackTrace __) { + _pending.remove(command); + }, + ); + return future; + } + + Future _confirm( + RecurringTimeCorrectionCommand command, + ) async => retryDelivery(await repository.confirm(command)); + + Future retryDelivery(ScheduleSaveReceipt receipt) { + final existing = _delivery[receipt]; + if (existing != null) return existing; + final future = _retryDelivery(receipt); + _delivery[receipt] = future; + future.then( + (_) { + _delivery[receipt] = null; + }, + onError: (Object _, StackTrace __) { + _delivery[receipt] = null; + }, + ); + return future; + } + + Future _retryDelivery( + ScheduleSaveReceipt receipt, + ) async { + if (!repository.isCurrent(receipt)) { + return receipt.withDeliveryPending(true); + } + try { + final complete = await effects.afterCommit(); + return receipt.withDeliveryPending( + !complete || !repository.isCurrent(receipt), + ); + } catch (_) { + return receipt.withDeliveryPending(true); + } + } +} diff --git a/lib/domain/use-cases/schedule_form_submission.dart b/lib/domain/use-cases/schedule_form_submission.dart index c2f784c5..995340b1 100644 --- a/lib/domain/use-cases/schedule_form_submission.dart +++ b/lib/domain/use-cases/schedule_form_submission.dart @@ -21,6 +21,11 @@ class ScheduleFormSubmission extends Equatable { final bool confirmDetached; final String? reviewedFirstSlotKey; + /// Ephemeral confirmation guard. Called synchronously by the existing writer + /// after receipt replay checks and immediately before transaction completion. + /// Not durable content and not part of the idempotent mutation digest. + final void Function()? validateTimeReview; + const ScheduleFormSubmission({ this.mutationId, this.baseline, @@ -35,6 +40,7 @@ class ScheduleFormSubmission extends Equatable { this.excludedSlots = const {}, this.confirmDetached = false, this.reviewedFirstSlotKey, + this.validateTimeReview, }); @override diff --git a/lib/domain/use-cases/schedule_preparation_session_use_case.dart b/lib/domain/use-cases/schedule_preparation_session_use_case.dart index 6148ca8d..4a729585 100644 --- a/lib/domain/use-cases/schedule_preparation_session_use_case.dart +++ b/lib/domain/use-cases/schedule_preparation_session_use_case.dart @@ -1,3 +1,7 @@ +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; @@ -49,7 +53,11 @@ class SchedulePreparationPromptResult { final ScheduleWithPreparationEntity? schedule; } -@Singleton() +Future disposePreparationSession( + SchedulePreparationSessionUseCase resource, +) => StartupDependencyScope.release(resource, resource.dispose); + +@Singleton(dispose: disposePreparationSession) class SchedulePreparationSessionUseCase { SchedulePreparationSessionUseCase( this._scheduleRepository, @@ -60,6 +68,7 @@ class SchedulePreparationSessionUseCase { this._reconcileAlarmsUseCase, { @ignoreParam AlarmOperationCoordinator? operations, }) : _operations = operations ?? AlarmOperationCoordinator.shared { + StartupDependencyScope.own(this, dispose); _operations.addListener(_discardOldGenerations); } @@ -70,12 +79,11 @@ class SchedulePreparationSessionUseCase { final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase; final ReconcileAlarmsUseCase _reconcileAlarmsUseCase; final AlarmOperationCoordinator _operations; - final _starts = <(int, String), Future>{}; + final _starts = <(int, String), Future>{}; final _runs = <(int, String), _StartRun>{}; final _projections = <(int, String), TimedPreparationSnapshotEntity>{}; final _epochs = <(int, String), int>{}; - @disposeMethod void dispose() { _operations.removeListener(_discardOldGenerations); _starts.clear(); @@ -139,12 +147,23 @@ class SchedulePreparationSessionUseCase { await _operations.run(lease, () async { if (!current()) throw const AlarmOperationInvalidated(); if (!run.initialized) { + final authoritative = await _scheduleRepository.getScheduleById( + schedule.id, + ); + if (!current()) throw const AlarmOperationInvalidated(); final stored = await _timedPreparationRepository .getTimedPreparationSnapshot(schedule.id); if (!current()) throw const AlarmOperationInvalidated(); final validated = stored == null ? null : validatePreparationSnapshot(stored, schedule); + if (authoritative.retainedRecurringReference && + (!authoritative.isStarted || + validated == null || + validated.requiresConfirmation || + validated.startedAt == null)) { + throw ScheduleStartRejected(schedule.id); + } // A view owner is not a Preparation Run identity. Only a validated // active snapshot resumes a previous run; DB startedAt alone never does. if (validated != null && @@ -218,13 +237,31 @@ class SchedulePreparationSessionUseCase { ); } - Future startSchedulePreparation(String scheduleId) { + Future startSchedulePreparation( + String scheduleId, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) { final lease = _operations.capture(); + if (isCurrent != null || expectedFingerprint != null) { + // Each guarded command owns its own intent. Do not coalesce a new intent + // with an older cancelled command merely because the schedule ID matches. + return _operations.run(lease, () async { + if (!(isCurrent?.call() ?? true)) { + throw ScheduleStartRejected(scheduleId); + } + return _scheduleRepository.startSchedule( + scheduleId, + isCurrent: isCurrent, + expectedFingerprint: expectedFingerprint, + ); + }); + } final key = (lease.generation, scheduleId); final pending = _starts[key]; if (pending != null) return pending; final future = _operations.run(lease, () async { - await _scheduleRepository.startSchedule(scheduleId); + return _scheduleRepository.startSchedule(scheduleId); }); _starts[key] = future; future.then( @@ -293,12 +330,29 @@ class SchedulePreparationSessionUseCase { void Function()? onInvalidated, }) async { final lease = _operations.capture(); + final interpretation = + schedule.timeResolution ?? + ScheduleTimeResolver.resolve(schedule, nowUtc: now.toUtc()); + if (interpretation.instantUtc == null) { + await clearPersistedState(schedule.id); + onInvalidated?.call(); + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + schedule.preparation, + timeResolution: interpretation, + ); + } final stored = await _timedPreparationRepository .getTimedPreparationSnapshot(schedule.id); lease.check(); - if (stored == null) return schedule; + if (stored == null) { + if (schedule.requiresStartConfirmation) onInvalidated?.call(); + return schedule; + } final snapshot = validatePreparationSnapshot(stored, schedule); - if (snapshot == null || snapshot.requiresConfirmation) { + if (schedule.requiresStartConfirmation || + snapshot == null || + snapshot.requiresConfirmation) { await clearPersistedState(schedule.id); await _operations.run( lease, @@ -333,7 +387,81 @@ class SchedulePreparationSessionUseCase { return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, restoredPreparation, + timeResolution: + schedule.timeResolution ?? + ScheduleTimeResolver.resolve(schedule, nowUtc: now.toUtc()), + ); + } + + /// Read-only: opening or cancelling deletion confirmation changes no run state. + Future assertDeletionAllowed( + ScheduleEditSnapshot snapshot, { + required AlarmOperationLease lease, + }) async { + lease.check(); + final schedule = snapshot.schedule; + if (schedule.isStarted) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.protected); + } + // A finalized outcome cannot be revived by stale reconstructible progress. + if (_isEnded(schedule.doneStatus)) return; + final key = (lease.generation, schedule.id); + if (_starts.containsKey(key) || _runs[key]?.flight != null) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.protected); + } + final early = await _earlyStartSessionRepository.getSession(schedule.id); + lease.check(); + final stored = await _timedPreparationRepository + .getTimedPreparationSnapshot(schedule.id); + lease.check(); + if (stored == null && early == null) return; + final resolution = ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now().toUtc(), ); + if (resolution.instantUtc == null) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.unavailable); + } + final value = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + PreparationWithTimeEntity.fromPreparation(snapshot.preparation), + timeResolution: resolution, + ); + final valid = stored == null + ? null + : validatePreparationSnapshot(stored, value); + if (valid != null && + valid.startedAt != null && + !valid.requiresConfirmation) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.protected); + } + if (early != null || (stored != null && valid == null)) { + throw const ScheduleDeletionRejected(ScheduleDeletionFailure.unavailable); + } + } + + /// Caller holds the existing delivery owner. Never enqueue recursively here. + Future clearDeletedStateUnderOwner( + String scheduleId, { + required AlarmOperationLease lease, + required Future Function() isCurrent, + }) async { + Future check() async { + lease.check(); + if (!await isCurrent()) throw const AlarmOperationInvalidated(); + lease.check(); + } + + await check(); + final key = (lease.generation, scheduleId); + _runs.remove(key); + _projections.remove(key); + _epochs[key] = (_epochs[key] ?? 0) + 1; + await _timedPreparationRepository.clearTimedPreparation(scheduleId); + await check(); + await _earlyStartSessionRepository.clear(scheduleId); + await check(); } Future clearPersistedState(String scheduleId) { @@ -368,6 +496,7 @@ class SchedulePreparationSessionUseCase { lease.check(); requestAlarmReconciliation(_reconcileAlarmsUseCase); if (!await _cancelDelivery(scheduleId)) return; + lease.check(); await clearPersistedState(scheduleId); } @@ -377,13 +506,15 @@ class SchedulePreparationSessionUseCase { String? scheduleFingerprint, bool Function()? isCurrent, }) async { + final evaluationNow = DateTime.now().toUtc(); bool current() => isCurrent?.call() ?? true; try { final schedule = await _scheduleRepository.getScheduleById(scheduleId); if (!current()) { return const SchedulePreparationPromptResult.unavailable(); } - if (_isEnded(schedule.doneStatus)) { + if (_isEnded(schedule.doneStatus) || + schedule.retainedRecurringReference) { if (isCurrent == null && !await _cancelDelivery(scheduleId)) { return const SchedulePreparationPromptResult.unavailable(); } @@ -428,7 +559,14 @@ class SchedulePreparationSessionUseCase { ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, PreparationWithTimeEntity.fromPreparation(preparation!), + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: evaluationNow, + ), ); + if (combined.timeResolution!.instantUtc == null) { + return const SchedulePreparationPromptResult.unavailable(); + } if (scheduleFingerprint != null && scheduleFingerprint != combined.cacheFingerprint && !startPreparation) { diff --git a/lib/domain/use-cases/schedule_time_correction_workflow.dart b/lib/domain/use-cases/schedule_time_correction_workflow.dart new file mode 100644 index 00000000..f5601e3d --- /dev/null +++ b/lib/domain/use-cases/schedule_time_correction_workflow.dart @@ -0,0 +1,67 @@ +import 'package:injectable/injectable.dart'; +import '../entities/schedule_save.dart'; +import '../entities/schedule_time_correction.dart'; +import '../repositories/schedule_time_correction_repository.dart'; +import 'schedule_mutation_alarm_effects_coordinator.dart'; + +@Injectable() +class ScheduleTimeCorrectionWorkflow { + ScheduleTimeCorrectionWorkflow(this.repository, this.effects); + final ScheduleTimeCorrectionRepository repository; + final ScheduleMutationAlarmEffectsCoordinator effects; + final _pending = + >{}; + final _delivery = Expando>(); + + Future confirm(ScheduleTimeCorrectionCommand command) { + final existing = _pending[command]; + if (existing != null) return existing; + final future = _confirm(command); + _pending[command] = future; + future.then( + (_) { + _pending.remove(command); + }, + onError: (Object _, StackTrace __) { + _pending.remove(command); + }, + ); + return future; + } + + Future _confirm( + ScheduleTimeCorrectionCommand command, + ) async => retryDelivery(await repository.confirm(command)); + + Future retryDelivery(ScheduleSaveReceipt receipt) { + final existing = _delivery[receipt]; + if (existing != null) return existing; + final future = _retryDelivery(receipt); + _delivery[receipt] = future; + future.then( + (_) { + _delivery[receipt] = null; + }, + onError: (Object _, StackTrace __) { + _delivery[receipt] = null; + }, + ); + return future; + } + + Future _retryDelivery( + ScheduleSaveReceipt receipt, + ) async { + if (!repository.isCurrent(receipt)) { + return receipt.withDeliveryPending(true); + } + try { + final complete = await effects.afterCommit(); + return receipt.withDeliveryPending( + !complete || !repository.isCurrent(receipt), + ); + } catch (_) { + return receipt.withDeliveryPending(true); + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c19f52ec..6d4ab7f5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,26 @@ { + "detailedNotificationTitle": "Show schedule names in notifications", + "detailedNotificationPrivacy": "Shows the schedule name and a differing time zone on the lock screen. Places, notes and preparation details stay private.", + "detailedNotificationLoading": "Checking setting", + "detailedNotificationReadFailed": "Unable to read the setting. Check the saved value again.", + "detailedNotificationUnavailable": "Check the setting again after the local data operation finishes.", + "detailedNotificationRequestOn": "On requested · not saved yet", + "detailedNotificationRequestOff": "Off requested · not saved yet", + "detailedNotificationSavedOn": "Saved: on", + "detailedNotificationSavedOff": "Saved: off", + "detailedNotificationSaveFailed": "The requested change could not be saved and has not been applied.", + "detailedNotificationApplying": "Applying the saved setting to notifications", + "detailedNotificationDelayed": "The device is taking longer to respond. Notification changes are still being checked.", + "detailedNotificationApplied": "Applied to current notification registrations", + "detailedNotificationOff": "Schedule notifications are off.", + "detailedNotificationEmpty": "There are no upcoming notifications to update.", + "detailedNotificationPermission": "Check notification permission. The saved setting is kept.", + "detailedNotificationCancellation": "Existing notifications may still show a schedule name. Check notification cleanup again.", + "detailedNotificationSchedulingFailed": "Some notifications could not be scheduled. Apply notifications again.", + "detailedNotificationNeedsCheck": "Notification changes could not be confirmed. Check again.", + "detailedNotificationHeld": "New detailed notifications are on hold until the off request is resolved. Existing notifications may remain.", + "detailedNotificationRetry": "Check or apply again", + "startupRecoveryTitle": "Unable to start OnTime", "startupRecoveryBody": "OnTime could not verify local data. Please try again shortly.", "startupRetryAction": "Try again", @@ -643,6 +665,8 @@ "dataFreshnessFailed": "Backup status could not be checked. Try again.", "dataRetry": "Try again", "dataRetryDelivery": "Retry notification processing only", + "dataRestoreCleanupPending": "Data was restored, but remaining cleanup and notification processing are incomplete. Retry the remaining work without restoring data again.", + "dataRetryCleanup": "Retry remaining work", "dataRestorePartial": "Data was restored, but notification processing is incomplete. You can retry notification processing only.", "dataUncommittedCleanup": "Data was not restored. Notification cleanup already began; retry notification processing for the current data.", "dataExportSaved": "Encrypted backup saved.", @@ -653,6 +677,7 @@ "dataUnavailable": "Local data is unavailable. Reopen the app.", "dataStalePreview": "Data changed after the preview. Select and review the backup again.", "dataInvalidBackup": "The operation could not be completed. Check the backup file and password.", + "dataStagingCleanupFailed": "Data was not restored, and temporary file cleanup is incomplete. Reopen the app to retry cleanup.", "dataOperationFailed": "The operation could not be completed. Try again.", "dataRestorePreviewTitle": "Review restore", "dataRestoreAction": "Restore", @@ -697,5 +722,131 @@ "scheduleCreateReview": "Prepare a new save using the current local data. Review your draft, then press Save again.", "scheduleDraftPreserved": "This is the currently saved schedule. Confirm to preserve your draft and prepare a new save. Review the changes, then press Save again.", "scheduleSaveProtected": "Schedules that have started or reached preparation time cannot be edited. Your draft is preserved.", - "scheduleSaveUnavailable": "Unable to save. Your draft is preserved. Check the current state and preparation steps." + "scheduleSaveUnavailable": "Unable to save. Your draft is preserved. Check the current state and preparation steps.", + "defaultPreferencesLoadFailed": "Could not load your settings. Please try again.", + "defaultPreferencesSaveFailed": "Settings were not saved. Your edits are still here. Please try again.", + "defaultPreferencesInvalid": "Check the preparation names, durations and spare time. Settings were not saved.", + "defaultPreferencesConflict": "Schedules or settings changed while you were editing. Your edits are still here and have not been saved. Reload the latest settings to review them.", + "defaultPreferencesReloadPending": "Settings are saved, but refreshing the displayed data is incomplete. Retry the refresh without saving again.", + "defaultPreferencesDeliveryPending": "Settings are saved, but notification setup is incomplete. Retry notification setup without saving again.", + "defaultPreferencesBothPending": "Settings are saved, but refreshing the displayed data and notification setup are incomplete. Retry only the remaining work without saving again.", + "defaultPreferencesSavedStoreChanged": "The local operation state changed after these settings were saved. This screen will not repeat the operation. Close it and review the current settings.", + "defaultPreferencesRetryFollowUp": "Retry remaining work", + "defaultPreferencesLoadLatest": "Reload latest settings", + "defaultPreferencesDiscardTitle": "Discard edits and reload?", + "defaultPreferencesDiscardDescription": "Loading the latest settings replaces your unsaved edits on this screen. If loading fails, your edits stay here.", + "defaultPreferencesMinutes": "{minutes} min", + "@defaultPreferencesMinutes": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "defaultPreferencesAuthorityPending": "Settings are saved, but the current local data could not be checked. Retry the remaining work without saving again.", + "zonedTimeOriginal": "Commitment time", + "zonedTimeDevice": "Device time", + "zonedTimeDeviceLoading": "Checking the device time zone.", + "zonedTimeDeviceUnavailable": "The device time zone is unavailable, so its equivalent time cannot be shown.", + "zonedTimeDeviceOutOfRange": "The equivalent device date is outside the supported range. The original commitment is preserved.", + "zonedTimeUnknownZone": "Review the time zone.", + "zonedTimeHistoricalUnknownZone": "The time zone rules for this historical record are unavailable. The stored commitment is preserved.", + "zonedTimeHistoricalUncertain": "The occurrence of this historical record is uncertain. The stored time is preserved.", + "zonedTimeNonexistent": "This time does not exist in this time zone. Choose another time.", + "zonedTimeAmbiguous": "This time occurs twice. Choose which occurrence to use.", + "zonedTimeRulesChanged": "Time zone rules have changed. Review the new occurrence.", + "zonedTimeInvalid": "Review the commitment time.", + "zonedTimeInstant": "Instant (UTC)", + "zonedTimeChooseZone": "Choose a time zone", + "zonedTimeSearch": "Search city or time zone", + "zonedTimeClearSearch": "Clear search", + "zonedTimeCurrentDeviceZone": "Current device time zone", + "zonedTimeCurrentSelection": "Current schedule time zone", + "zonedTimeDraftPreview": "Selection preview", + "zonedTimeKeepCivil": "Keep the wall date and time while changing the time zone. Review the commitment before saving.", + "zonedTimeNoMatches": "No matching time zones. Try a city name or IANA identifier.", + "zonedTimeApplyDraft": "Apply selection", + "zonedTimeReviewTitle": "Review commitment time", + "zonedTimeReviewDescription": "Review the commitment and its time on this device before saving.", + "zonedTimeBefore": "Before change", + "zonedTimeAfter": "Commitment to save", + "zonedTimeConfirmSave": "Confirm and save", + "zonedTimeFirstOccurrence": "First occurrence", + "zonedTimeSecondOccurrence": "Second occurrence", + "zonedTimeDetectedZone": "Detected device time zone", + "zonedTimeSelectedZone": "Selected by you", + "zonedTimeSavedZone": "Saved schedule time zone", + "zonedTimeSelectUnavailableZone": "Device time zone unavailable. Select one to continue.", + "zonedTimeNextValid": "Review next valid time", + "zonedTimePreparationStart": "Preparation starts", + "zonedCityAsiaSeoul": "Seoul", + "zonedCityAsiaTokyo": "Tokyo", + "zonedCityAsiaShanghai": "Shanghai", + "zonedCityAsiaHongKong": "Hong Kong", + "zonedCityAsiaTaipei": "Taipei", + "zonedCityAsiaSingapore": "Singapore", + "zonedCityAsiaBangkok": "Bangkok", + "zonedCityAsiaDubai": "Dubai", + "zonedCityAsiaKolkata": "Kolkata", + "zonedCityAsiaKathmandu": "Kathmandu", + "zonedCityEuropeLondon": "London", + "zonedCityEuropeParis": "Paris", + "zonedCityEuropeBerlin": "Berlin", + "zonedCityEuropeRome": "Rome", + "zonedCityEuropeMadrid": "Madrid", + "zonedCityEuropeMoscow": "Moscow", + "zonedCityAmericaNewYork": "New York", + "zonedCityAmericaLosAngeles": "Los Angeles", + "zonedCityAmericaChicago": "Chicago", + "zonedCityAmericaDenver": "Denver", + "zonedCityAmericaToronto": "Toronto", + "zonedCityAmericaVancouver": "Vancouver", + "zonedCityAmericaSaoPaulo": "Sao Paulo", + "zonedCityAustraliaSydney": "Sydney", + "zonedCityAustraliaMelbourne": "Melbourne", + "zonedCityAustraliaPerth": "Perth", + "zonedCityAustraliaLordHowe": "Lord Howe", + "zonedCityPacificAuckland": "Auckland", + "zonedCityPacificHonolulu": "Honolulu", + "zonedCityPacificApia": "Apia", + "zonedCityAfricaCairo": "Cairo", + "zonedCityAfricaJohannesburg": "Johannesburg", + "zonedCityUtc": "UTC", + "zonedTimeHomeDateBasis": "Today · device date", + "zonedTimeCalendarDateBasis": "Calendar · commitment date", + "zonedTimeDateBasisTitle": "Why dates can differ", + "zonedTimeDateBasisDescription": "Today on Home groups appointments by the current device date. The month calendar places each appointment on its original commitment date and time zone. The same instant can fall on different dates, so both dates and zones are shown when they differ.", + "zonedTimeAllReviewedOccurrences": "View all reviewed occurrences", + "zonedTimeReviewScopeOccurrence": "Changes: this occurrence", + "zonedTimeReviewScopeFollowing": "Changes: this and following occurrences", + "zonedTimeReviewScopeNew": "New recurring schedule", + "zonedTimePreparationUnavailable": "The preparation start cannot be displayed with the available time zone rules. Review it again before saving.", + "homeNextAppointment": "Next appointment", + "homePreparationInProgress": "Preparation in progress", + "homePreparationPrompt": "Appointment to prepare for", + "homePreviouslyCheckedAppointment": "Previously checked appointment", + "homeCheckingAppointments": "Checking upcoming appointments.", + "homeNoUpcomingAppointments": "No upcoming appointments.", + "homeQueryFailed": "Could not check appointments. Please try again.", + "homeQueryCancelled": "Appointment checking was cancelled. You can review your appointments in the calendar.", + "homeQueryInterrupted": "Appointment checking was interrupted. Continue checking when you are ready.", + "homeQueryLimited": "Could not check all appointments. Review your appointments or repeat settings in the calendar.", + "homeQueryStale": "These are previously checked details. Recheck them before starting preparation from this card.", + "homeRetryQuery": "Retry", + "homeContinueQuery": "Continue checking", + "homeCancelQuery": "Cancel checking", + "homeTimeIssues": "Some appointment times need review. Check their dates and time zones in the calendar.", + "homeReviewCalendar": "Review calendar", + "scheduleDeletionRemovedTitle": "Schedule history removed", + "scheduleDeletionComplete": "The schedule history and its delivery registrations have been removed.", + "scheduleDeletionCleaning": "History has been removed. Delivery cleanup is in progress.", + "scheduleDeletionPending": "History has been removed, but delivery cleanup is not yet confirmed. You can retry when the current operation finishes.", + "scheduleDeletionPreparationActive": "Preparation is in progress. Finish it on the preparation screen before deleting this schedule.", + "scheduleDeletionConsequences": "The selected schedule’s details, private preparation and outcome will be removed. Other schedules, shared content and existing punctuality totals are preserved. There is no undo in the app. Restoring an older exported backup can bring this history back.", + "scheduleDeletionFollowing": "Delete this and later unstarted occurrences.", + "scheduleDeletionOnlySelected": "Delete only the selected schedule. Other schedules and recurrence rules are preserved.", + "scheduleDeletionAlreadyAbsent": "This schedule is already absent from the current data. No additional data was deleted.", + "scheduleDeletionRetryCleanup": "Retry delivery cleanup", + "scheduleDeletionAbsentTitle": "Schedule already absent", + "scheduleDeletionFollowingConsequences": "Delete this and following upcoming, unstarted occurrences in this series and stop generating later occurrences. Individually edited following occurrences are also included. Their names, notes and preparation content that no other schedule shares will be removed. Past history, active or preparation-frozen occurrences, other series, shared content and existing punctuality totals are preserved. There is no undo in the app. Existing exported backup files are unchanged; restoring an older backup can bring back deleted occurrences and their recurrence." } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 173caa8e..3e471b8e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,26 @@ { + "detailedNotificationTitle": "알림에 일정 이름 표시", + "detailedNotificationPrivacy": "잠금화면에 일정 이름과 다른 시간대만 표시합니다. 장소, 메모, 준비 내용은 표시하지 않습니다.", + "detailedNotificationLoading": "설정 확인 중", + "detailedNotificationReadFailed": "설정을 불러오지 못했습니다. 저장된 값을 확인해주세요.", + "detailedNotificationUnavailable": "로컬 데이터 변경이 끝난 뒤 설정을 다시 확인해주세요.", + "detailedNotificationRequestOn": "켬 요청 중 · 아직 저장되지 않음", + "detailedNotificationRequestOff": "끔 요청 중 · 아직 저장되지 않음", + "detailedNotificationSavedOn": "켬으로 저장됨", + "detailedNotificationSavedOff": "끔으로 저장됨", + "detailedNotificationSaveFailed": "요청한 변경을 저장하지 못했습니다. 아직 적용되지 않았습니다.", + "detailedNotificationApplying": "저장된 설정을 알림에 반영 중", + "detailedNotificationDelayed": "기기의 응답이 늦어지고 있습니다. 알림 적용을 계속 확인 중입니다.", + "detailedNotificationApplied": "현재 알림 등록에 반영됨", + "detailedNotificationOff": "일정 알림이 꺼져 있습니다.", + "detailedNotificationEmpty": "현재 반영할 예정 알림이 없습니다.", + "detailedNotificationPermission": "알림 권한을 확인해주세요. 저장된 설정은 유지됩니다.", + "detailedNotificationCancellation": "기존 알림에 일정 이름이 남아 있을 수 있습니다. 알림 정리를 다시 확인해주세요.", + "detailedNotificationSchedulingFailed": "일부 알림을 예약하지 못했습니다. 알림을 다시 적용해주세요.", + "detailedNotificationNeedsCheck": "알림 적용 여부를 확인하지 못했습니다. 다시 확인해주세요.", + "detailedNotificationHeld": "끔 요청이 해결될 때까지 새 상세 알림 등록을 보류합니다. 기존 알림이 제거됐다는 뜻은 아닙니다.", + "detailedNotificationRetry": "다시 확인·적용", + "startupRecoveryTitle": "앱을 시작할 수 없습니다", "startupRecoveryBody": "로컬 데이터를 확인하는 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.", "startupRetryAction": "다시 시도", @@ -240,6 +262,8 @@ "dataFreshnessFailed": "백업 상태를 확인하지 못했습니다. 다시 시도해 주세요.", "dataRetry": "다시 시도", "dataRetryDelivery": "알림 처리만 다시 시도", + "dataRestoreCleanupPending": "데이터는 복원됐지만 남은 정리와 알림 처리가 완료되지 않았습니다. 데이터를 다시 복원하지 않고 남은 처리만 다시 시도할 수 있습니다.", + "dataRetryCleanup": "남은 처리 다시 시도", "dataRestorePartial": "데이터는 복원됐지만 알림 처리가 완료되지 않았습니다. 알림 처리만 다시 시도할 수 있습니다.", "dataUncommittedCleanup": "데이터는 복원되지 않았습니다. 이미 시작된 알림 정리를 마무리하려면 현재 데이터의 알림 처리를 다시 시도해 주세요.", "dataExportSaved": "암호화 백업을 저장했습니다.", @@ -250,6 +274,7 @@ "dataUnavailable": "로컬 데이터를 사용할 수 없습니다. 앱을 다시 열어 주세요.", "dataStalePreview": "미리보기 이후 데이터가 변경됐습니다. 백업을 다시 선택하고 확인해 주세요.", "dataInvalidBackup": "작업을 완료하지 못했습니다. 백업 파일과 비밀번호를 확인해 주세요.", + "dataStagingCleanupFailed": "데이터는 복원되지 않았고 임시 파일 정리가 완료되지 않았습니다. 앱을 다시 열면 정리를 다시 시도합니다.", "dataOperationFailed": "작업을 완료하지 못했습니다. 다시 시도해 주세요.", "dataRestorePreviewTitle": "복원 내용 확인", "dataRestoreAction": "복원", @@ -294,5 +319,131 @@ "scheduleCreateReview": "현재 로컬 데이터를 기준으로 새 저장을 준비합니다. 입력 내용을 확인한 뒤 저장 버튼을 다시 눌러 주세요.", "scheduleDraftPreserved": "위 내용은 현재 저장된 일정입니다. 확인하면 입력한 초안을 유지하며 새 저장을 준비합니다. 변경 내용을 검토한 뒤 저장을 다시 눌러 주세요.", "scheduleSaveProtected": "이미 시작됐거나 준비 시간이 지난 일정은 수정할 수 없어요. 입력 내용은 유지됩니다.", - "scheduleSaveUnavailable": "저장할 수 없어요. 입력을 유지했으니 현재 상태와 준비 단계를 확인해 주세요." + "scheduleSaveUnavailable": "저장할 수 없어요. 입력을 유지했으니 현재 상태와 준비 단계를 확인해 주세요.", + "defaultPreferencesLoadFailed": "설정을 불러오지 못했어요. 다시 불러와 주세요.", + "defaultPreferencesSaveFailed": "설정을 저장하지 못했어요. 입력은 그대로 남아 있어요. 다시 시도해 주세요.", + "defaultPreferencesInvalid": "준비 이름과 시간, 여유 시간을 확인해 주세요. 설정은 저장되지 않았어요.", + "defaultPreferencesConflict": "편집 중 일정 또는 설정이 변경되었어요. 입력은 남아 있지만 아직 저장되지 않았어요. 최신 설정을 다시 불러온 뒤 확인해 주세요.", + "defaultPreferencesReloadPending": "설정은 저장되었지만 화면 갱신을 마치지 못했어요. 다시 저장하지 않고 화면 갱신만 재시도할 수 있어요.", + "defaultPreferencesDeliveryPending": "설정은 저장되었지만 알림 처리를 마치지 못했어요. 다시 저장하지 않고 알림 처리만 재시도할 수 있어요.", + "defaultPreferencesBothPending": "설정은 저장되었지만 화면 갱신과 알림 처리를 마치지 못했어요. 다시 저장하지 않고 남은 작업만 재시도할 수 있어요.", + "defaultPreferencesSavedStoreChanged": "설정을 저장한 뒤 로컬 작업 상태가 달라졌어요. 이 화면의 작업은 다시 실행하지 않아요. 화면을 닫고 현재 설정을 확인해 주세요.", + "defaultPreferencesRetryFollowUp": "남은 작업 다시 시도", + "defaultPreferencesLoadLatest": "최신 설정 다시 불러오기", + "defaultPreferencesDiscardTitle": "편집 내용을 버리고 다시 불러올까요?", + "defaultPreferencesDiscardDescription": "최신 설정을 불러오면 이 화면의 저장하지 않은 편집 내용이 바뀌어요. 불러오지 못하면 편집 내용은 유지돼요.", + "defaultPreferencesMinutes": "{minutes}분", + "@defaultPreferencesMinutes": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "defaultPreferencesAuthorityPending": "설정은 저장되었지만 현재 로컬 데이터를 확인하지 못했어요. 다시 저장하지 않고 남은 작업을 재시도할 수 있어요.", + "zonedTimeOriginal": "약속 시간", + "zonedTimeDevice": "기기 시간", + "zonedTimeDeviceLoading": "기기 시간대를 확인하고 있어요.", + "zonedTimeDeviceUnavailable": "기기 시간대를 확인할 수 없어 환산 시각을 표시하지 못해요.", + "zonedTimeDeviceOutOfRange": "기기 환산 날짜가 지원 범위를 벗어나요. 원래 약속 시간은 유지됩니다.", + "zonedTimeUnknownZone": "시간대를 확인해주세요.", + "zonedTimeHistoricalUnknownZone": "이 기록의 시간대 규칙을 찾을 수 없어요. 저장된 약속은 그대로 보존됩니다.", + "zonedTimeHistoricalUncertain": "과거 기록의 발생 시각을 확정할 수 없어요. 저장된 시간은 그대로 보존됩니다.", + "zonedTimeNonexistent": "이 시간대에는 존재하지 않는 시각이에요. 다른 시각을 선택해주세요.", + "zonedTimeAmbiguous": "두 번 발생하는 시각이에요. 사용할 발생 시각을 선택해주세요.", + "zonedTimeRulesChanged": "시간대 규칙이 달라졌어요. 변경할 발생 시각을 확인해주세요.", + "zonedTimeInvalid": "약속 시간을 확인해주세요.", + "zonedTimeInstant": "실제 시각 (UTC)", + "zonedTimeChooseZone": "시간대 선택", + "zonedTimeSearch": "도시 또는 시간대 검색", + "zonedTimeClearSearch": "검색어 지우기", + "zonedTimeCurrentDeviceZone": "현재 기기 시간대", + "zonedTimeCurrentSelection": "현재 일정 시간대", + "zonedTimeDraftPreview": "선택 미리보기", + "zonedTimeKeepCivil": "날짜와 시각은 유지하고 시간대를 변경해요. 저장 전 약속 시간을 다시 확인합니다.", + "zonedTimeNoMatches": "일치하는 시간대가 없어요. 도시 이름이나 IANA 식별자로 다시 검색해주세요.", + "zonedTimeApplyDraft": "선택 적용", + "zonedTimeReviewTitle": "약속 시간 확인", + "zonedTimeReviewDescription": "저장할 약속과 기기에서의 시각을 확인해주세요.", + "zonedTimeBefore": "변경 전", + "zonedTimeAfter": "저장할 약속", + "zonedTimeConfirmSave": "확인하고 저장", + "zonedTimeFirstOccurrence": "첫 번째 발생", + "zonedTimeSecondOccurrence": "두 번째 발생", + "zonedTimeDetectedZone": "기기에서 확인한 시간대", + "zonedTimeSelectedZone": "직접 선택한 시간대", + "zonedTimeSavedZone": "저장된 일정 시간대", + "zonedTimeSelectUnavailableZone": "기기 시간대를 확인할 수 없어요. 직접 선택해주세요.", + "zonedTimeNextValid": "다음 유효 시각 확인", + "zonedTimePreparationStart": "준비 시작", + "zonedCityAsiaSeoul": "서울", + "zonedCityAsiaTokyo": "도쿄", + "zonedCityAsiaShanghai": "상하이", + "zonedCityAsiaHongKong": "홍콩", + "zonedCityAsiaTaipei": "타이베이", + "zonedCityAsiaSingapore": "싱가포르", + "zonedCityAsiaBangkok": "방콕", + "zonedCityAsiaDubai": "두바이", + "zonedCityAsiaKolkata": "콜카타", + "zonedCityAsiaKathmandu": "카트만두", + "zonedCityEuropeLondon": "런던", + "zonedCityEuropeParis": "파리", + "zonedCityEuropeBerlin": "베를린", + "zonedCityEuropeRome": "로마", + "zonedCityEuropeMadrid": "마드리드", + "zonedCityEuropeMoscow": "모스크바", + "zonedCityAmericaNewYork": "뉴욕", + "zonedCityAmericaLosAngeles": "로스앤젤레스", + "zonedCityAmericaChicago": "시카고", + "zonedCityAmericaDenver": "덴버", + "zonedCityAmericaToronto": "토론토", + "zonedCityAmericaVancouver": "밴쿠버", + "zonedCityAmericaSaoPaulo": "상파울루", + "zonedCityAustraliaSydney": "시드니", + "zonedCityAustraliaMelbourne": "멜버른", + "zonedCityAustraliaPerth": "퍼스", + "zonedCityAustraliaLordHowe": "로드하우", + "zonedCityPacificAuckland": "오클랜드", + "zonedCityPacificHonolulu": "호놀룰루", + "zonedCityPacificApia": "아피아", + "zonedCityAfricaCairo": "카이로", + "zonedCityAfricaJohannesburg": "요하네스버그", + "zonedCityUtc": "협정 세계시", + "zonedTimeHomeDateBasis": "오늘 · 기기 날짜 기준", + "zonedTimeCalendarDateBasis": "달력 · 원래 약속 날짜 기준", + "zonedTimeDateBasisTitle": "날짜가 다른 이유", + "zonedTimeDateBasisDescription": "홈의 오늘 일정은 현재 기기 시간대의 날짜로 모읍니다. 월 달력은 각 약속의 원래 시간대와 날짜에 배치합니다. 약속과 기기 시간대가 다르면 같은 순간이 서로 다른 날짜일 수 있어 두 날짜와 시간대를 함께 표시합니다.", + "zonedTimeAllReviewedOccurrences": "검토 중인 회차 모두 보기", + "zonedTimeReviewScopeOccurrence": "변경 범위: 이번 회차", + "zonedTimeReviewScopeFollowing": "변경 범위: 이번 회차와 이후", + "zonedTimeReviewScopeNew": "새 반복 일정", + "zonedTimePreparationUnavailable": "현재 시간대 규칙으로 준비 시작을 표시할 수 없어요. 저장 전에 다시 검토해주세요.", + "homeNextAppointment": "다음 약속", + "homePreparationInProgress": "진행 중인 준비", + "homePreparationPrompt": "준비할 약속", + "homePreviouslyCheckedAppointment": "이전에 확인한 약속", + "homeCheckingAppointments": "다음 약속을 확인하고 있어요.", + "homeNoUpcomingAppointments": "예정된 약속이 없어요.", + "homeQueryFailed": "약속을 확인하지 못했어요. 다시 시도해 주세요.", + "homeQueryCancelled": "약속 확인을 중단했어요. 캘린더에서 약속을 확인할 수 있어요.", + "homeQueryInterrupted": "약속 확인이 중단됐어요. 이어서 확인해 주세요.", + "homeQueryLimited": "모든 약속을 확인하지 못했어요. 캘린더에서 약속이나 반복 설정을 확인해 주세요.", + "homeQueryStale": "아래는 이전에 확인한 내용이에요. 다시 확인하기 전에는 이 카드에서 준비를 시작할 수 없어요.", + "homeRetryQuery": "다시 확인", + "homeContinueQuery": "이어서 확인", + "homeCancelQuery": "확인 중단", + "homeTimeIssues": "시간을 확인해야 하는 약속이 있어요. 캘린더에서 해당 약속의 날짜와 시간대를 검토해 주세요.", + "homeReviewCalendar": "캘린더에서 확인", + "scheduleDeletionRemovedTitle": "일정 이력이 삭제됐어요", + "scheduleDeletionComplete": "일정 이력과 알림 정리가 완료됐어요.", + "scheduleDeletionCleaning": "이력은 삭제됐고 알림을 정리하고 있어요.", + "scheduleDeletionPending": "이력은 삭제됐지만 알림 정리가 아직 확인되지 않았어요. 정리가 끝나면 다시 확인할 수 있어요.", + "scheduleDeletionPreparationActive": "준비가 진행 중이에요. 준비 화면에서 먼저 종료한 뒤 삭제해 주세요.", + "scheduleDeletionConsequences": "선택한 일정의 이름, 메모, 개별 준비와 결과를 삭제해요. 다른 일정과 공유 중인 내용 및 이미 반영된 시간 엄수 점수는 유지돼요. 앱에서 실행 취소할 수 없으며, 이전에 내보낸 백업을 복원하면 이력이 다시 나타날 수 있어요.", + "scheduleDeletionFollowing": "선택한 회차와 이후의 미진행 회차를 삭제해요.", + "scheduleDeletionOnlySelected": "선택한 일정만 삭제해요. 다른 일정과 반복 규칙은 유지돼요.", + "scheduleDeletionAlreadyAbsent": "이 일정은 현재 데이터에 이미 없어요. 새로 삭제한 데이터는 없어요.", + "scheduleDeletionRetryCleanup": "알림 정리 다시 확인", + "scheduleDeletionAbsentTitle": "이미 없는 일정", + "scheduleDeletionFollowingConsequences": "이 반복 일정의 선택한 회차부터 이후 미진행 회차를 삭제하고, 이후 반복 생성을 종료해요. 개별로 수정한 이후 회차도 삭제 대상에 포함돼요. 삭제 대상의 이름, 메모와 다른 일정에서 공유하지 않는 준비 내용을 삭제해요. 과거 이력, 진행 중이거나 준비가 고정된 회차, 다른 반복 일정, 공유 중인 내용과 이미 반영된 시간 엄수 점수는 유지돼요. 앱에서 실행 취소할 수 없어요. 이미 내보낸 백업 파일은 바뀌지 않으며, 이전 백업을 복원하면 삭제한 회차와 반복이 다시 나타날 수 있어요." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 44e55696..a30973c1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,6 +98,132 @@ abstract class AppLocalizations { Locale('ko'), ]; + /// No description provided for @detailedNotificationTitle. + /// + /// In en, this message translates to: + /// **'Show schedule names in notifications'** + String get detailedNotificationTitle; + + /// No description provided for @detailedNotificationPrivacy. + /// + /// In en, this message translates to: + /// **'Shows the schedule name and a differing time zone on the lock screen. Places, notes and preparation details stay private.'** + String get detailedNotificationPrivacy; + + /// No description provided for @detailedNotificationLoading. + /// + /// In en, this message translates to: + /// **'Checking setting'** + String get detailedNotificationLoading; + + /// No description provided for @detailedNotificationReadFailed. + /// + /// In en, this message translates to: + /// **'Unable to read the setting. Check the saved value again.'** + String get detailedNotificationReadFailed; + + /// No description provided for @detailedNotificationUnavailable. + /// + /// In en, this message translates to: + /// **'Check the setting again after the local data operation finishes.'** + String get detailedNotificationUnavailable; + + /// No description provided for @detailedNotificationRequestOn. + /// + /// In en, this message translates to: + /// **'On requested · not saved yet'** + String get detailedNotificationRequestOn; + + /// No description provided for @detailedNotificationRequestOff. + /// + /// In en, this message translates to: + /// **'Off requested · not saved yet'** + String get detailedNotificationRequestOff; + + /// No description provided for @detailedNotificationSavedOn. + /// + /// In en, this message translates to: + /// **'Saved: on'** + String get detailedNotificationSavedOn; + + /// No description provided for @detailedNotificationSavedOff. + /// + /// In en, this message translates to: + /// **'Saved: off'** + String get detailedNotificationSavedOff; + + /// No description provided for @detailedNotificationSaveFailed. + /// + /// In en, this message translates to: + /// **'The requested change could not be saved and has not been applied.'** + String get detailedNotificationSaveFailed; + + /// No description provided for @detailedNotificationApplying. + /// + /// In en, this message translates to: + /// **'Applying the saved setting to notifications'** + String get detailedNotificationApplying; + + /// No description provided for @detailedNotificationDelayed. + /// + /// In en, this message translates to: + /// **'The device is taking longer to respond. Notification changes are still being checked.'** + String get detailedNotificationDelayed; + + /// No description provided for @detailedNotificationApplied. + /// + /// In en, this message translates to: + /// **'Applied to current notification registrations'** + String get detailedNotificationApplied; + + /// No description provided for @detailedNotificationOff. + /// + /// In en, this message translates to: + /// **'Schedule notifications are off.'** + String get detailedNotificationOff; + + /// No description provided for @detailedNotificationEmpty. + /// + /// In en, this message translates to: + /// **'There are no upcoming notifications to update.'** + String get detailedNotificationEmpty; + + /// No description provided for @detailedNotificationPermission. + /// + /// In en, this message translates to: + /// **'Check notification permission. The saved setting is kept.'** + String get detailedNotificationPermission; + + /// No description provided for @detailedNotificationCancellation. + /// + /// In en, this message translates to: + /// **'Existing notifications may still show a schedule name. Check notification cleanup again.'** + String get detailedNotificationCancellation; + + /// No description provided for @detailedNotificationSchedulingFailed. + /// + /// In en, this message translates to: + /// **'Some notifications could not be scheduled. Apply notifications again.'** + String get detailedNotificationSchedulingFailed; + + /// No description provided for @detailedNotificationNeedsCheck. + /// + /// In en, this message translates to: + /// **'Notification changes could not be confirmed. Check again.'** + String get detailedNotificationNeedsCheck; + + /// No description provided for @detailedNotificationHeld. + /// + /// In en, this message translates to: + /// **'New detailed notifications are on hold until the off request is resolved. Existing notifications may remain.'** + String get detailedNotificationHeld; + + /// No description provided for @detailedNotificationRetry. + /// + /// In en, this message translates to: + /// **'Check or apply again'** + String get detailedNotificationRetry; + /// No description provided for @startupRecoveryTitle. /// /// In en, this message translates to: @@ -1172,6 +1298,18 @@ abstract class AppLocalizations { /// **'Retry notification processing only'** String get dataRetryDelivery; + /// No description provided for @dataRestoreCleanupPending. + /// + /// In en, this message translates to: + /// **'Data was restored, but remaining cleanup and notification processing are incomplete. Retry the remaining work without restoring data again.'** + String get dataRestoreCleanupPending; + + /// No description provided for @dataRetryCleanup. + /// + /// In en, this message translates to: + /// **'Retry remaining work'** + String get dataRetryCleanup; + /// No description provided for @dataRestorePartial. /// /// In en, this message translates to: @@ -1232,6 +1370,12 @@ abstract class AppLocalizations { /// **'The operation could not be completed. Check the backup file and password.'** String get dataInvalidBackup; + /// No description provided for @dataStagingCleanupFailed. + /// + /// In en, this message translates to: + /// **'Data was not restored, and temporary file cleanup is incomplete. Reopen the app to retry cleanup.'** + String get dataStagingCleanupFailed; + /// No description provided for @dataOperationFailed. /// /// In en, this message translates to: @@ -1376,6 +1520,720 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Unable to save. Your draft is preserved. Check the current state and preparation steps.'** String get scheduleSaveUnavailable; + + /// No description provided for @defaultPreferencesLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not load your settings. Please try again.'** + String get defaultPreferencesLoadFailed; + + /// No description provided for @defaultPreferencesSaveFailed. + /// + /// In en, this message translates to: + /// **'Settings were not saved. Your edits are still here. Please try again.'** + String get defaultPreferencesSaveFailed; + + /// No description provided for @defaultPreferencesInvalid. + /// + /// In en, this message translates to: + /// **'Check the preparation names, durations and spare time. Settings were not saved.'** + String get defaultPreferencesInvalid; + + /// No description provided for @defaultPreferencesConflict. + /// + /// In en, this message translates to: + /// **'Schedules or settings changed while you were editing. Your edits are still here and have not been saved. Reload the latest settings to review them.'** + String get defaultPreferencesConflict; + + /// No description provided for @defaultPreferencesReloadPending. + /// + /// In en, this message translates to: + /// **'Settings are saved, but refreshing the displayed data is incomplete. Retry the refresh without saving again.'** + String get defaultPreferencesReloadPending; + + /// No description provided for @defaultPreferencesDeliveryPending. + /// + /// In en, this message translates to: + /// **'Settings are saved, but notification setup is incomplete. Retry notification setup without saving again.'** + String get defaultPreferencesDeliveryPending; + + /// No description provided for @defaultPreferencesBothPending. + /// + /// In en, this message translates to: + /// **'Settings are saved, but refreshing the displayed data and notification setup are incomplete. Retry only the remaining work without saving again.'** + String get defaultPreferencesBothPending; + + /// No description provided for @defaultPreferencesSavedStoreChanged. + /// + /// In en, this message translates to: + /// **'The local operation state changed after these settings were saved. This screen will not repeat the operation. Close it and review the current settings.'** + String get defaultPreferencesSavedStoreChanged; + + /// No description provided for @defaultPreferencesRetryFollowUp. + /// + /// In en, this message translates to: + /// **'Retry remaining work'** + String get defaultPreferencesRetryFollowUp; + + /// No description provided for @defaultPreferencesLoadLatest. + /// + /// In en, this message translates to: + /// **'Reload latest settings'** + String get defaultPreferencesLoadLatest; + + /// No description provided for @defaultPreferencesDiscardTitle. + /// + /// In en, this message translates to: + /// **'Discard edits and reload?'** + String get defaultPreferencesDiscardTitle; + + /// No description provided for @defaultPreferencesDiscardDescription. + /// + /// In en, this message translates to: + /// **'Loading the latest settings replaces your unsaved edits on this screen. If loading fails, your edits stay here.'** + String get defaultPreferencesDiscardDescription; + + /// No description provided for @defaultPreferencesMinutes. + /// + /// In en, this message translates to: + /// **'{minutes} min'** + String defaultPreferencesMinutes(int minutes); + + /// No description provided for @defaultPreferencesAuthorityPending. + /// + /// In en, this message translates to: + /// **'Settings are saved, but the current local data could not be checked. Retry the remaining work without saving again.'** + String get defaultPreferencesAuthorityPending; + + /// No description provided for @zonedTimeOriginal. + /// + /// In en, this message translates to: + /// **'Commitment time'** + String get zonedTimeOriginal; + + /// No description provided for @zonedTimeDevice. + /// + /// In en, this message translates to: + /// **'Device time'** + String get zonedTimeDevice; + + /// No description provided for @zonedTimeDeviceLoading. + /// + /// In en, this message translates to: + /// **'Checking the device time zone.'** + String get zonedTimeDeviceLoading; + + /// No description provided for @zonedTimeDeviceUnavailable. + /// + /// In en, this message translates to: + /// **'The device time zone is unavailable, so its equivalent time cannot be shown.'** + String get zonedTimeDeviceUnavailable; + + /// No description provided for @zonedTimeDeviceOutOfRange. + /// + /// In en, this message translates to: + /// **'The equivalent device date is outside the supported range. The original commitment is preserved.'** + String get zonedTimeDeviceOutOfRange; + + /// No description provided for @zonedTimeUnknownZone. + /// + /// In en, this message translates to: + /// **'Review the time zone.'** + String get zonedTimeUnknownZone; + + /// No description provided for @zonedTimeHistoricalUnknownZone. + /// + /// In en, this message translates to: + /// **'The time zone rules for this historical record are unavailable. The stored commitment is preserved.'** + String get zonedTimeHistoricalUnknownZone; + + /// No description provided for @zonedTimeHistoricalUncertain. + /// + /// In en, this message translates to: + /// **'The occurrence of this historical record is uncertain. The stored time is preserved.'** + String get zonedTimeHistoricalUncertain; + + /// No description provided for @zonedTimeNonexistent. + /// + /// In en, this message translates to: + /// **'This time does not exist in this time zone. Choose another time.'** + String get zonedTimeNonexistent; + + /// No description provided for @zonedTimeAmbiguous. + /// + /// In en, this message translates to: + /// **'This time occurs twice. Choose which occurrence to use.'** + String get zonedTimeAmbiguous; + + /// No description provided for @zonedTimeRulesChanged. + /// + /// In en, this message translates to: + /// **'Time zone rules have changed. Review the new occurrence.'** + String get zonedTimeRulesChanged; + + /// No description provided for @zonedTimeInvalid. + /// + /// In en, this message translates to: + /// **'Review the commitment time.'** + String get zonedTimeInvalid; + + /// No description provided for @zonedTimeInstant. + /// + /// In en, this message translates to: + /// **'Instant (UTC)'** + String get zonedTimeInstant; + + /// No description provided for @zonedTimeChooseZone. + /// + /// In en, this message translates to: + /// **'Choose a time zone'** + String get zonedTimeChooseZone; + + /// No description provided for @zonedTimeSearch. + /// + /// In en, this message translates to: + /// **'Search city or time zone'** + String get zonedTimeSearch; + + /// No description provided for @zonedTimeClearSearch. + /// + /// In en, this message translates to: + /// **'Clear search'** + String get zonedTimeClearSearch; + + /// No description provided for @zonedTimeCurrentDeviceZone. + /// + /// In en, this message translates to: + /// **'Current device time zone'** + String get zonedTimeCurrentDeviceZone; + + /// No description provided for @zonedTimeCurrentSelection. + /// + /// In en, this message translates to: + /// **'Current schedule time zone'** + String get zonedTimeCurrentSelection; + + /// No description provided for @zonedTimeDraftPreview. + /// + /// In en, this message translates to: + /// **'Selection preview'** + String get zonedTimeDraftPreview; + + /// No description provided for @zonedTimeKeepCivil. + /// + /// In en, this message translates to: + /// **'Keep the wall date and time while changing the time zone. Review the commitment before saving.'** + String get zonedTimeKeepCivil; + + /// No description provided for @zonedTimeNoMatches. + /// + /// In en, this message translates to: + /// **'No matching time zones. Try a city name or IANA identifier.'** + String get zonedTimeNoMatches; + + /// No description provided for @zonedTimeApplyDraft. + /// + /// In en, this message translates to: + /// **'Apply selection'** + String get zonedTimeApplyDraft; + + /// No description provided for @zonedTimeReviewTitle. + /// + /// In en, this message translates to: + /// **'Review commitment time'** + String get zonedTimeReviewTitle; + + /// No description provided for @zonedTimeReviewDescription. + /// + /// In en, this message translates to: + /// **'Review the commitment and its time on this device before saving.'** + String get zonedTimeReviewDescription; + + /// No description provided for @zonedTimeBefore. + /// + /// In en, this message translates to: + /// **'Before change'** + String get zonedTimeBefore; + + /// No description provided for @zonedTimeAfter. + /// + /// In en, this message translates to: + /// **'Commitment to save'** + String get zonedTimeAfter; + + /// No description provided for @zonedTimeConfirmSave. + /// + /// In en, this message translates to: + /// **'Confirm and save'** + String get zonedTimeConfirmSave; + + /// No description provided for @zonedTimeFirstOccurrence. + /// + /// In en, this message translates to: + /// **'First occurrence'** + String get zonedTimeFirstOccurrence; + + /// No description provided for @zonedTimeSecondOccurrence. + /// + /// In en, this message translates to: + /// **'Second occurrence'** + String get zonedTimeSecondOccurrence; + + /// No description provided for @zonedTimeDetectedZone. + /// + /// In en, this message translates to: + /// **'Detected device time zone'** + String get zonedTimeDetectedZone; + + /// No description provided for @zonedTimeSelectedZone. + /// + /// In en, this message translates to: + /// **'Selected by you'** + String get zonedTimeSelectedZone; + + /// No description provided for @zonedTimeSavedZone. + /// + /// In en, this message translates to: + /// **'Saved schedule time zone'** + String get zonedTimeSavedZone; + + /// No description provided for @zonedTimeSelectUnavailableZone. + /// + /// In en, this message translates to: + /// **'Device time zone unavailable. Select one to continue.'** + String get zonedTimeSelectUnavailableZone; + + /// No description provided for @zonedTimeNextValid. + /// + /// In en, this message translates to: + /// **'Review next valid time'** + String get zonedTimeNextValid; + + /// No description provided for @zonedTimePreparationStart. + /// + /// In en, this message translates to: + /// **'Preparation starts'** + String get zonedTimePreparationStart; + + /// No description provided for @zonedCityAsiaSeoul. + /// + /// In en, this message translates to: + /// **'Seoul'** + String get zonedCityAsiaSeoul; + + /// No description provided for @zonedCityAsiaTokyo. + /// + /// In en, this message translates to: + /// **'Tokyo'** + String get zonedCityAsiaTokyo; + + /// No description provided for @zonedCityAsiaShanghai. + /// + /// In en, this message translates to: + /// **'Shanghai'** + String get zonedCityAsiaShanghai; + + /// No description provided for @zonedCityAsiaHongKong. + /// + /// In en, this message translates to: + /// **'Hong Kong'** + String get zonedCityAsiaHongKong; + + /// No description provided for @zonedCityAsiaTaipei. + /// + /// In en, this message translates to: + /// **'Taipei'** + String get zonedCityAsiaTaipei; + + /// No description provided for @zonedCityAsiaSingapore. + /// + /// In en, this message translates to: + /// **'Singapore'** + String get zonedCityAsiaSingapore; + + /// No description provided for @zonedCityAsiaBangkok. + /// + /// In en, this message translates to: + /// **'Bangkok'** + String get zonedCityAsiaBangkok; + + /// No description provided for @zonedCityAsiaDubai. + /// + /// In en, this message translates to: + /// **'Dubai'** + String get zonedCityAsiaDubai; + + /// No description provided for @zonedCityAsiaKolkata. + /// + /// In en, this message translates to: + /// **'Kolkata'** + String get zonedCityAsiaKolkata; + + /// No description provided for @zonedCityAsiaKathmandu. + /// + /// In en, this message translates to: + /// **'Kathmandu'** + String get zonedCityAsiaKathmandu; + + /// No description provided for @zonedCityEuropeLondon. + /// + /// In en, this message translates to: + /// **'London'** + String get zonedCityEuropeLondon; + + /// No description provided for @zonedCityEuropeParis. + /// + /// In en, this message translates to: + /// **'Paris'** + String get zonedCityEuropeParis; + + /// No description provided for @zonedCityEuropeBerlin. + /// + /// In en, this message translates to: + /// **'Berlin'** + String get zonedCityEuropeBerlin; + + /// No description provided for @zonedCityEuropeRome. + /// + /// In en, this message translates to: + /// **'Rome'** + String get zonedCityEuropeRome; + + /// No description provided for @zonedCityEuropeMadrid. + /// + /// In en, this message translates to: + /// **'Madrid'** + String get zonedCityEuropeMadrid; + + /// No description provided for @zonedCityEuropeMoscow. + /// + /// In en, this message translates to: + /// **'Moscow'** + String get zonedCityEuropeMoscow; + + /// No description provided for @zonedCityAmericaNewYork. + /// + /// In en, this message translates to: + /// **'New York'** + String get zonedCityAmericaNewYork; + + /// No description provided for @zonedCityAmericaLosAngeles. + /// + /// In en, this message translates to: + /// **'Los Angeles'** + String get zonedCityAmericaLosAngeles; + + /// No description provided for @zonedCityAmericaChicago. + /// + /// In en, this message translates to: + /// **'Chicago'** + String get zonedCityAmericaChicago; + + /// No description provided for @zonedCityAmericaDenver. + /// + /// In en, this message translates to: + /// **'Denver'** + String get zonedCityAmericaDenver; + + /// No description provided for @zonedCityAmericaToronto. + /// + /// In en, this message translates to: + /// **'Toronto'** + String get zonedCityAmericaToronto; + + /// No description provided for @zonedCityAmericaVancouver. + /// + /// In en, this message translates to: + /// **'Vancouver'** + String get zonedCityAmericaVancouver; + + /// No description provided for @zonedCityAmericaSaoPaulo. + /// + /// In en, this message translates to: + /// **'Sao Paulo'** + String get zonedCityAmericaSaoPaulo; + + /// No description provided for @zonedCityAustraliaSydney. + /// + /// In en, this message translates to: + /// **'Sydney'** + String get zonedCityAustraliaSydney; + + /// No description provided for @zonedCityAustraliaMelbourne. + /// + /// In en, this message translates to: + /// **'Melbourne'** + String get zonedCityAustraliaMelbourne; + + /// No description provided for @zonedCityAustraliaPerth. + /// + /// In en, this message translates to: + /// **'Perth'** + String get zonedCityAustraliaPerth; + + /// No description provided for @zonedCityAustraliaLordHowe. + /// + /// In en, this message translates to: + /// **'Lord Howe'** + String get zonedCityAustraliaLordHowe; + + /// No description provided for @zonedCityPacificAuckland. + /// + /// In en, this message translates to: + /// **'Auckland'** + String get zonedCityPacificAuckland; + + /// No description provided for @zonedCityPacificHonolulu. + /// + /// In en, this message translates to: + /// **'Honolulu'** + String get zonedCityPacificHonolulu; + + /// No description provided for @zonedCityPacificApia. + /// + /// In en, this message translates to: + /// **'Apia'** + String get zonedCityPacificApia; + + /// No description provided for @zonedCityAfricaCairo. + /// + /// In en, this message translates to: + /// **'Cairo'** + String get zonedCityAfricaCairo; + + /// No description provided for @zonedCityAfricaJohannesburg. + /// + /// In en, this message translates to: + /// **'Johannesburg'** + String get zonedCityAfricaJohannesburg; + + /// No description provided for @zonedCityUtc. + /// + /// In en, this message translates to: + /// **'UTC'** + String get zonedCityUtc; + + /// No description provided for @zonedTimeHomeDateBasis. + /// + /// In en, this message translates to: + /// **'Today · device date'** + String get zonedTimeHomeDateBasis; + + /// No description provided for @zonedTimeCalendarDateBasis. + /// + /// In en, this message translates to: + /// **'Calendar · commitment date'** + String get zonedTimeCalendarDateBasis; + + /// No description provided for @zonedTimeDateBasisTitle. + /// + /// In en, this message translates to: + /// **'Why dates can differ'** + String get zonedTimeDateBasisTitle; + + /// No description provided for @zonedTimeDateBasisDescription. + /// + /// In en, this message translates to: + /// **'Today on Home groups appointments by the current device date. The month calendar places each appointment on its original commitment date and time zone. The same instant can fall on different dates, so both dates and zones are shown when they differ.'** + String get zonedTimeDateBasisDescription; + + /// No description provided for @zonedTimeAllReviewedOccurrences. + /// + /// In en, this message translates to: + /// **'View all reviewed occurrences'** + String get zonedTimeAllReviewedOccurrences; + + /// No description provided for @zonedTimeReviewScopeOccurrence. + /// + /// In en, this message translates to: + /// **'Changes: this occurrence'** + String get zonedTimeReviewScopeOccurrence; + + /// No description provided for @zonedTimeReviewScopeFollowing. + /// + /// In en, this message translates to: + /// **'Changes: this and following occurrences'** + String get zonedTimeReviewScopeFollowing; + + /// No description provided for @zonedTimeReviewScopeNew. + /// + /// In en, this message translates to: + /// **'New recurring schedule'** + String get zonedTimeReviewScopeNew; + + /// No description provided for @zonedTimePreparationUnavailable. + /// + /// In en, this message translates to: + /// **'The preparation start cannot be displayed with the available time zone rules. Review it again before saving.'** + String get zonedTimePreparationUnavailable; + + /// No description provided for @homeNextAppointment. + /// + /// In en, this message translates to: + /// **'Next appointment'** + String get homeNextAppointment; + + /// No description provided for @homePreparationInProgress. + /// + /// In en, this message translates to: + /// **'Preparation in progress'** + String get homePreparationInProgress; + + /// No description provided for @homePreparationPrompt. + /// + /// In en, this message translates to: + /// **'Appointment to prepare for'** + String get homePreparationPrompt; + + /// No description provided for @homePreviouslyCheckedAppointment. + /// + /// In en, this message translates to: + /// **'Previously checked appointment'** + String get homePreviouslyCheckedAppointment; + + /// No description provided for @homeCheckingAppointments. + /// + /// In en, this message translates to: + /// **'Checking upcoming appointments.'** + String get homeCheckingAppointments; + + /// No description provided for @homeNoUpcomingAppointments. + /// + /// In en, this message translates to: + /// **'No upcoming appointments.'** + String get homeNoUpcomingAppointments; + + /// No description provided for @homeQueryFailed. + /// + /// In en, this message translates to: + /// **'Could not check appointments. Please try again.'** + String get homeQueryFailed; + + /// No description provided for @homeQueryCancelled. + /// + /// In en, this message translates to: + /// **'Appointment checking was cancelled. You can review your appointments in the calendar.'** + String get homeQueryCancelled; + + /// No description provided for @homeQueryInterrupted. + /// + /// In en, this message translates to: + /// **'Appointment checking was interrupted. Continue checking when you are ready.'** + String get homeQueryInterrupted; + + /// No description provided for @homeQueryLimited. + /// + /// In en, this message translates to: + /// **'Could not check all appointments. Review your appointments or repeat settings in the calendar.'** + String get homeQueryLimited; + + /// No description provided for @homeQueryStale. + /// + /// In en, this message translates to: + /// **'These are previously checked details. Recheck them before starting preparation from this card.'** + String get homeQueryStale; + + /// No description provided for @homeRetryQuery. + /// + /// In en, this message translates to: + /// **'Retry'** + String get homeRetryQuery; + + /// No description provided for @homeContinueQuery. + /// + /// In en, this message translates to: + /// **'Continue checking'** + String get homeContinueQuery; + + /// No description provided for @homeCancelQuery. + /// + /// In en, this message translates to: + /// **'Cancel checking'** + String get homeCancelQuery; + + /// No description provided for @homeTimeIssues. + /// + /// In en, this message translates to: + /// **'Some appointment times need review. Check their dates and time zones in the calendar.'** + String get homeTimeIssues; + + /// No description provided for @homeReviewCalendar. + /// + /// In en, this message translates to: + /// **'Review calendar'** + String get homeReviewCalendar; + + /// No description provided for @scheduleDeletionRemovedTitle. + /// + /// In en, this message translates to: + /// **'Schedule history removed'** + String get scheduleDeletionRemovedTitle; + + /// No description provided for @scheduleDeletionComplete. + /// + /// In en, this message translates to: + /// **'The schedule history and its delivery registrations have been removed.'** + String get scheduleDeletionComplete; + + /// No description provided for @scheduleDeletionCleaning. + /// + /// In en, this message translates to: + /// **'History has been removed. Delivery cleanup is in progress.'** + String get scheduleDeletionCleaning; + + /// No description provided for @scheduleDeletionPending. + /// + /// In en, this message translates to: + /// **'History has been removed, but delivery cleanup is not yet confirmed. You can retry when the current operation finishes.'** + String get scheduleDeletionPending; + + /// No description provided for @scheduleDeletionPreparationActive. + /// + /// In en, this message translates to: + /// **'Preparation is in progress. Finish it on the preparation screen before deleting this schedule.'** + String get scheduleDeletionPreparationActive; + + /// No description provided for @scheduleDeletionConsequences. + /// + /// In en, this message translates to: + /// **'The selected schedule’s details, private preparation and outcome will be removed. Other schedules, shared content and existing punctuality totals are preserved. There is no undo in the app. Restoring an older exported backup can bring this history back.'** + String get scheduleDeletionConsequences; + + /// No description provided for @scheduleDeletionFollowing. + /// + /// In en, this message translates to: + /// **'Delete this and later unstarted occurrences.'** + String get scheduleDeletionFollowing; + + /// No description provided for @scheduleDeletionOnlySelected. + /// + /// In en, this message translates to: + /// **'Delete only the selected schedule. Other schedules and recurrence rules are preserved.'** + String get scheduleDeletionOnlySelected; + + /// No description provided for @scheduleDeletionAlreadyAbsent. + /// + /// In en, this message translates to: + /// **'This schedule is already absent from the current data. No additional data was deleted.'** + String get scheduleDeletionAlreadyAbsent; + + /// No description provided for @scheduleDeletionRetryCleanup. + /// + /// In en, this message translates to: + /// **'Retry delivery cleanup'** + String get scheduleDeletionRetryCleanup; + + /// No description provided for @scheduleDeletionAbsentTitle. + /// + /// In en, this message translates to: + /// **'Schedule already absent'** + String get scheduleDeletionAbsentTitle; + + /// No description provided for @scheduleDeletionFollowingConsequences. + /// + /// In en, this message translates to: + /// **'Delete this and following upcoming, unstarted occurrences in this series and stop generating later occurrences. Individually edited following occurrences are also included. Their names, notes and preparation content that no other schedule shares will be removed. Past history, active or preparation-frozen occurrences, other series, shared content and existing punctuality totals are preserved. There is no undo in the app. Existing exported backup files are unchanged; restoring an older backup can bring back deleted occurrences and their recurrence.'** + String get scheduleDeletionFollowingConsequences; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7110322b..3cc1ffba 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,6 +8,83 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get detailedNotificationTitle => + 'Show schedule names in notifications'; + + @override + String get detailedNotificationPrivacy => + 'Shows the schedule name and a differing time zone on the lock screen. Places, notes and preparation details stay private.'; + + @override + String get detailedNotificationLoading => 'Checking setting'; + + @override + String get detailedNotificationReadFailed => + 'Unable to read the setting. Check the saved value again.'; + + @override + String get detailedNotificationUnavailable => + 'Check the setting again after the local data operation finishes.'; + + @override + String get detailedNotificationRequestOn => 'On requested · not saved yet'; + + @override + String get detailedNotificationRequestOff => 'Off requested · not saved yet'; + + @override + String get detailedNotificationSavedOn => 'Saved: on'; + + @override + String get detailedNotificationSavedOff => 'Saved: off'; + + @override + String get detailedNotificationSaveFailed => + 'The requested change could not be saved and has not been applied.'; + + @override + String get detailedNotificationApplying => + 'Applying the saved setting to notifications'; + + @override + String get detailedNotificationDelayed => + 'The device is taking longer to respond. Notification changes are still being checked.'; + + @override + String get detailedNotificationApplied => + 'Applied to current notification registrations'; + + @override + String get detailedNotificationOff => 'Schedule notifications are off.'; + + @override + String get detailedNotificationEmpty => + 'There are no upcoming notifications to update.'; + + @override + String get detailedNotificationPermission => + 'Check notification permission. The saved setting is kept.'; + + @override + String get detailedNotificationCancellation => + 'Existing notifications may still show a schedule name. Check notification cleanup again.'; + + @override + String get detailedNotificationSchedulingFailed => + 'Some notifications could not be scheduled. Apply notifications again.'; + + @override + String get detailedNotificationNeedsCheck => + 'Notification changes could not be confirmed. Check again.'; + + @override + String get detailedNotificationHeld => + 'New detailed notifications are on hold until the off request is resolved. Existing notifications may remain.'; + + @override + String get detailedNotificationRetry => 'Check or apply again'; + @override String get startupRecoveryTitle => 'Unable to start OnTime'; @@ -631,6 +708,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dataRetryDelivery => 'Retry notification processing only'; + @override + String get dataRestoreCleanupPending => + 'Data was restored, but remaining cleanup and notification processing are incomplete. Retry the remaining work without restoring data again.'; + + @override + String get dataRetryCleanup => 'Retry remaining work'; + @override String get dataRestorePartial => 'Data was restored, but notification processing is incomplete. You can retry notification processing only.'; @@ -668,6 +752,10 @@ class AppLocalizationsEn extends AppLocalizations { String get dataInvalidBackup => 'The operation could not be completed. Check the backup file and password.'; + @override + String get dataStagingCleanupFailed => + 'Data was not restored, and temporary file cleanup is incomplete. Reopen the app to retry cleanup.'; + @override String get dataOperationFailed => 'The operation could not be completed. Try again.'; @@ -754,4 +842,403 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scheduleSaveUnavailable => 'Unable to save. Your draft is preserved. Check the current state and preparation steps.'; + + @override + String get defaultPreferencesLoadFailed => + 'Could not load your settings. Please try again.'; + + @override + String get defaultPreferencesSaveFailed => + 'Settings were not saved. Your edits are still here. Please try again.'; + + @override + String get defaultPreferencesInvalid => + 'Check the preparation names, durations and spare time. Settings were not saved.'; + + @override + String get defaultPreferencesConflict => + 'Schedules or settings changed while you were editing. Your edits are still here and have not been saved. Reload the latest settings to review them.'; + + @override + String get defaultPreferencesReloadPending => + 'Settings are saved, but refreshing the displayed data is incomplete. Retry the refresh without saving again.'; + + @override + String get defaultPreferencesDeliveryPending => + 'Settings are saved, but notification setup is incomplete. Retry notification setup without saving again.'; + + @override + String get defaultPreferencesBothPending => + 'Settings are saved, but refreshing the displayed data and notification setup are incomplete. Retry only the remaining work without saving again.'; + + @override + String get defaultPreferencesSavedStoreChanged => + 'The local operation state changed after these settings were saved. This screen will not repeat the operation. Close it and review the current settings.'; + + @override + String get defaultPreferencesRetryFollowUp => 'Retry remaining work'; + + @override + String get defaultPreferencesLoadLatest => 'Reload latest settings'; + + @override + String get defaultPreferencesDiscardTitle => 'Discard edits and reload?'; + + @override + String get defaultPreferencesDiscardDescription => + 'Loading the latest settings replaces your unsaved edits on this screen. If loading fails, your edits stay here.'; + + @override + String defaultPreferencesMinutes(int minutes) { + return '$minutes min'; + } + + @override + String get defaultPreferencesAuthorityPending => + 'Settings are saved, but the current local data could not be checked. Retry the remaining work without saving again.'; + + @override + String get zonedTimeOriginal => 'Commitment time'; + + @override + String get zonedTimeDevice => 'Device time'; + + @override + String get zonedTimeDeviceLoading => 'Checking the device time zone.'; + + @override + String get zonedTimeDeviceUnavailable => + 'The device time zone is unavailable, so its equivalent time cannot be shown.'; + + @override + String get zonedTimeDeviceOutOfRange => + 'The equivalent device date is outside the supported range. The original commitment is preserved.'; + + @override + String get zonedTimeUnknownZone => 'Review the time zone.'; + + @override + String get zonedTimeHistoricalUnknownZone => + 'The time zone rules for this historical record are unavailable. The stored commitment is preserved.'; + + @override + String get zonedTimeHistoricalUncertain => + 'The occurrence of this historical record is uncertain. The stored time is preserved.'; + + @override + String get zonedTimeNonexistent => + 'This time does not exist in this time zone. Choose another time.'; + + @override + String get zonedTimeAmbiguous => + 'This time occurs twice. Choose which occurrence to use.'; + + @override + String get zonedTimeRulesChanged => + 'Time zone rules have changed. Review the new occurrence.'; + + @override + String get zonedTimeInvalid => 'Review the commitment time.'; + + @override + String get zonedTimeInstant => 'Instant (UTC)'; + + @override + String get zonedTimeChooseZone => 'Choose a time zone'; + + @override + String get zonedTimeSearch => 'Search city or time zone'; + + @override + String get zonedTimeClearSearch => 'Clear search'; + + @override + String get zonedTimeCurrentDeviceZone => 'Current device time zone'; + + @override + String get zonedTimeCurrentSelection => 'Current schedule time zone'; + + @override + String get zonedTimeDraftPreview => 'Selection preview'; + + @override + String get zonedTimeKeepCivil => + 'Keep the wall date and time while changing the time zone. Review the commitment before saving.'; + + @override + String get zonedTimeNoMatches => + 'No matching time zones. Try a city name or IANA identifier.'; + + @override + String get zonedTimeApplyDraft => 'Apply selection'; + + @override + String get zonedTimeReviewTitle => 'Review commitment time'; + + @override + String get zonedTimeReviewDescription => + 'Review the commitment and its time on this device before saving.'; + + @override + String get zonedTimeBefore => 'Before change'; + + @override + String get zonedTimeAfter => 'Commitment to save'; + + @override + String get zonedTimeConfirmSave => 'Confirm and save'; + + @override + String get zonedTimeFirstOccurrence => 'First occurrence'; + + @override + String get zonedTimeSecondOccurrence => 'Second occurrence'; + + @override + String get zonedTimeDetectedZone => 'Detected device time zone'; + + @override + String get zonedTimeSelectedZone => 'Selected by you'; + + @override + String get zonedTimeSavedZone => 'Saved schedule time zone'; + + @override + String get zonedTimeSelectUnavailableZone => + 'Device time zone unavailable. Select one to continue.'; + + @override + String get zonedTimeNextValid => 'Review next valid time'; + + @override + String get zonedTimePreparationStart => 'Preparation starts'; + + @override + String get zonedCityAsiaSeoul => 'Seoul'; + + @override + String get zonedCityAsiaTokyo => 'Tokyo'; + + @override + String get zonedCityAsiaShanghai => 'Shanghai'; + + @override + String get zonedCityAsiaHongKong => 'Hong Kong'; + + @override + String get zonedCityAsiaTaipei => 'Taipei'; + + @override + String get zonedCityAsiaSingapore => 'Singapore'; + + @override + String get zonedCityAsiaBangkok => 'Bangkok'; + + @override + String get zonedCityAsiaDubai => 'Dubai'; + + @override + String get zonedCityAsiaKolkata => 'Kolkata'; + + @override + String get zonedCityAsiaKathmandu => 'Kathmandu'; + + @override + String get zonedCityEuropeLondon => 'London'; + + @override + String get zonedCityEuropeParis => 'Paris'; + + @override + String get zonedCityEuropeBerlin => 'Berlin'; + + @override + String get zonedCityEuropeRome => 'Rome'; + + @override + String get zonedCityEuropeMadrid => 'Madrid'; + + @override + String get zonedCityEuropeMoscow => 'Moscow'; + + @override + String get zonedCityAmericaNewYork => 'New York'; + + @override + String get zonedCityAmericaLosAngeles => 'Los Angeles'; + + @override + String get zonedCityAmericaChicago => 'Chicago'; + + @override + String get zonedCityAmericaDenver => 'Denver'; + + @override + String get zonedCityAmericaToronto => 'Toronto'; + + @override + String get zonedCityAmericaVancouver => 'Vancouver'; + + @override + String get zonedCityAmericaSaoPaulo => 'Sao Paulo'; + + @override + String get zonedCityAustraliaSydney => 'Sydney'; + + @override + String get zonedCityAustraliaMelbourne => 'Melbourne'; + + @override + String get zonedCityAustraliaPerth => 'Perth'; + + @override + String get zonedCityAustraliaLordHowe => 'Lord Howe'; + + @override + String get zonedCityPacificAuckland => 'Auckland'; + + @override + String get zonedCityPacificHonolulu => 'Honolulu'; + + @override + String get zonedCityPacificApia => 'Apia'; + + @override + String get zonedCityAfricaCairo => 'Cairo'; + + @override + String get zonedCityAfricaJohannesburg => 'Johannesburg'; + + @override + String get zonedCityUtc => 'UTC'; + + @override + String get zonedTimeHomeDateBasis => 'Today · device date'; + + @override + String get zonedTimeCalendarDateBasis => 'Calendar · commitment date'; + + @override + String get zonedTimeDateBasisTitle => 'Why dates can differ'; + + @override + String get zonedTimeDateBasisDescription => + 'Today on Home groups appointments by the current device date. The month calendar places each appointment on its original commitment date and time zone. The same instant can fall on different dates, so both dates and zones are shown when they differ.'; + + @override + String get zonedTimeAllReviewedOccurrences => 'View all reviewed occurrences'; + + @override + String get zonedTimeReviewScopeOccurrence => 'Changes: this occurrence'; + + @override + String get zonedTimeReviewScopeFollowing => + 'Changes: this and following occurrences'; + + @override + String get zonedTimeReviewScopeNew => 'New recurring schedule'; + + @override + String get zonedTimePreparationUnavailable => + 'The preparation start cannot be displayed with the available time zone rules. Review it again before saving.'; + + @override + String get homeNextAppointment => 'Next appointment'; + + @override + String get homePreparationInProgress => 'Preparation in progress'; + + @override + String get homePreparationPrompt => 'Appointment to prepare for'; + + @override + String get homePreviouslyCheckedAppointment => + 'Previously checked appointment'; + + @override + String get homeCheckingAppointments => 'Checking upcoming appointments.'; + + @override + String get homeNoUpcomingAppointments => 'No upcoming appointments.'; + + @override + String get homeQueryFailed => + 'Could not check appointments. Please try again.'; + + @override + String get homeQueryCancelled => + 'Appointment checking was cancelled. You can review your appointments in the calendar.'; + + @override + String get homeQueryInterrupted => + 'Appointment checking was interrupted. Continue checking when you are ready.'; + + @override + String get homeQueryLimited => + 'Could not check all appointments. Review your appointments or repeat settings in the calendar.'; + + @override + String get homeQueryStale => + 'These are previously checked details. Recheck them before starting preparation from this card.'; + + @override + String get homeRetryQuery => 'Retry'; + + @override + String get homeContinueQuery => 'Continue checking'; + + @override + String get homeCancelQuery => 'Cancel checking'; + + @override + String get homeTimeIssues => + 'Some appointment times need review. Check their dates and time zones in the calendar.'; + + @override + String get homeReviewCalendar => 'Review calendar'; + + @override + String get scheduleDeletionRemovedTitle => 'Schedule history removed'; + + @override + String get scheduleDeletionComplete => + 'The schedule history and its delivery registrations have been removed.'; + + @override + String get scheduleDeletionCleaning => + 'History has been removed. Delivery cleanup is in progress.'; + + @override + String get scheduleDeletionPending => + 'History has been removed, but delivery cleanup is not yet confirmed. You can retry when the current operation finishes.'; + + @override + String get scheduleDeletionPreparationActive => + 'Preparation is in progress. Finish it on the preparation screen before deleting this schedule.'; + + @override + String get scheduleDeletionConsequences => + 'The selected schedule’s details, private preparation and outcome will be removed. Other schedules, shared content and existing punctuality totals are preserved. There is no undo in the app. Restoring an older exported backup can bring this history back.'; + + @override + String get scheduleDeletionFollowing => + 'Delete this and later unstarted occurrences.'; + + @override + String get scheduleDeletionOnlySelected => + 'Delete only the selected schedule. Other schedules and recurrence rules are preserved.'; + + @override + String get scheduleDeletionAlreadyAbsent => + 'This schedule is already absent from the current data. No additional data was deleted.'; + + @override + String get scheduleDeletionRetryCleanup => 'Retry delivery cleanup'; + + @override + String get scheduleDeletionAbsentTitle => 'Schedule already absent'; + + @override + String get scheduleDeletionFollowingConsequences => + 'Delete this and following upcoming, unstarted occurrences in this series and stop generating later occurrences. Individually edited following occurrences are also included. Their names, notes and preparation content that no other schedule shares will be removed. Past history, active or preparation-frozen occurrences, other series, shared content and existing punctuality totals are preserved. There is no undo in the app. Existing exported backup files are unchanged; restoring an older backup can bring back deleted occurrences and their recurrence.'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 1fa4bd5f..00272a0b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -8,6 +8,77 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get detailedNotificationTitle => '알림에 일정 이름 표시'; + + @override + String get detailedNotificationPrivacy => + '잠금화면에 일정 이름과 다른 시간대만 표시합니다. 장소, 메모, 준비 내용은 표시하지 않습니다.'; + + @override + String get detailedNotificationLoading => '설정 확인 중'; + + @override + String get detailedNotificationReadFailed => '설정을 불러오지 못했습니다. 저장된 값을 확인해주세요.'; + + @override + String get detailedNotificationUnavailable => + '로컬 데이터 변경이 끝난 뒤 설정을 다시 확인해주세요.'; + + @override + String get detailedNotificationRequestOn => '켬 요청 중 · 아직 저장되지 않음'; + + @override + String get detailedNotificationRequestOff => '끔 요청 중 · 아직 저장되지 않음'; + + @override + String get detailedNotificationSavedOn => '켬으로 저장됨'; + + @override + String get detailedNotificationSavedOff => '끔으로 저장됨'; + + @override + String get detailedNotificationSaveFailed => + '요청한 변경을 저장하지 못했습니다. 아직 적용되지 않았습니다.'; + + @override + String get detailedNotificationApplying => '저장된 설정을 알림에 반영 중'; + + @override + String get detailedNotificationDelayed => + '기기의 응답이 늦어지고 있습니다. 알림 적용을 계속 확인 중입니다.'; + + @override + String get detailedNotificationApplied => '현재 알림 등록에 반영됨'; + + @override + String get detailedNotificationOff => '일정 알림이 꺼져 있습니다.'; + + @override + String get detailedNotificationEmpty => '현재 반영할 예정 알림이 없습니다.'; + + @override + String get detailedNotificationPermission => '알림 권한을 확인해주세요. 저장된 설정은 유지됩니다.'; + + @override + String get detailedNotificationCancellation => + '기존 알림에 일정 이름이 남아 있을 수 있습니다. 알림 정리를 다시 확인해주세요.'; + + @override + String get detailedNotificationSchedulingFailed => + '일부 알림을 예약하지 못했습니다. 알림을 다시 적용해주세요.'; + + @override + String get detailedNotificationNeedsCheck => + '알림 적용 여부를 확인하지 못했습니다. 다시 확인해주세요.'; + + @override + String get detailedNotificationHeld => + '끔 요청이 해결될 때까지 새 상세 알림 등록을 보류합니다. 기존 알림이 제거됐다는 뜻은 아닙니다.'; + + @override + String get detailedNotificationRetry => '다시 확인·적용'; + @override String get startupRecoveryTitle => '앱을 시작할 수 없습니다'; @@ -585,6 +656,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get dataRetryDelivery => '알림 처리만 다시 시도'; + @override + String get dataRestoreCleanupPending => + '데이터는 복원됐지만 남은 정리와 알림 처리가 완료되지 않았습니다. 데이터를 다시 복원하지 않고 남은 처리만 다시 시도할 수 있습니다.'; + + @override + String get dataRetryCleanup => '남은 처리 다시 시도'; + @override String get dataRestorePartial => '데이터는 복원됐지만 알림 처리가 완료되지 않았습니다. 알림 처리만 다시 시도할 수 있습니다.'; @@ -618,6 +696,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get dataInvalidBackup => '작업을 완료하지 못했습니다. 백업 파일과 비밀번호를 확인해 주세요.'; + @override + String get dataStagingCleanupFailed => + '데이터는 복원되지 않았고 임시 파일 정리가 완료되지 않았습니다. 앱을 다시 열면 정리를 다시 시도합니다.'; + @override String get dataOperationFailed => '작업을 완료하지 못했습니다. 다시 시도해 주세요.'; @@ -701,4 +783,387 @@ class AppLocalizationsKo extends AppLocalizations { @override String get scheduleSaveUnavailable => '저장할 수 없어요. 입력을 유지했으니 현재 상태와 준비 단계를 확인해 주세요.'; + + @override + String get defaultPreferencesLoadFailed => '설정을 불러오지 못했어요. 다시 불러와 주세요.'; + + @override + String get defaultPreferencesSaveFailed => + '설정을 저장하지 못했어요. 입력은 그대로 남아 있어요. 다시 시도해 주세요.'; + + @override + String get defaultPreferencesInvalid => + '준비 이름과 시간, 여유 시간을 확인해 주세요. 설정은 저장되지 않았어요.'; + + @override + String get defaultPreferencesConflict => + '편집 중 일정 또는 설정이 변경되었어요. 입력은 남아 있지만 아직 저장되지 않았어요. 최신 설정을 다시 불러온 뒤 확인해 주세요.'; + + @override + String get defaultPreferencesReloadPending => + '설정은 저장되었지만 화면 갱신을 마치지 못했어요. 다시 저장하지 않고 화면 갱신만 재시도할 수 있어요.'; + + @override + String get defaultPreferencesDeliveryPending => + '설정은 저장되었지만 알림 처리를 마치지 못했어요. 다시 저장하지 않고 알림 처리만 재시도할 수 있어요.'; + + @override + String get defaultPreferencesBothPending => + '설정은 저장되었지만 화면 갱신과 알림 처리를 마치지 못했어요. 다시 저장하지 않고 남은 작업만 재시도할 수 있어요.'; + + @override + String get defaultPreferencesSavedStoreChanged => + '설정을 저장한 뒤 로컬 작업 상태가 달라졌어요. 이 화면의 작업은 다시 실행하지 않아요. 화면을 닫고 현재 설정을 확인해 주세요.'; + + @override + String get defaultPreferencesRetryFollowUp => '남은 작업 다시 시도'; + + @override + String get defaultPreferencesLoadLatest => '최신 설정 다시 불러오기'; + + @override + String get defaultPreferencesDiscardTitle => '편집 내용을 버리고 다시 불러올까요?'; + + @override + String get defaultPreferencesDiscardDescription => + '최신 설정을 불러오면 이 화면의 저장하지 않은 편집 내용이 바뀌어요. 불러오지 못하면 편집 내용은 유지돼요.'; + + @override + String defaultPreferencesMinutes(int minutes) { + return '$minutes분'; + } + + @override + String get defaultPreferencesAuthorityPending => + '설정은 저장되었지만 현재 로컬 데이터를 확인하지 못했어요. 다시 저장하지 않고 남은 작업을 재시도할 수 있어요.'; + + @override + String get zonedTimeOriginal => '약속 시간'; + + @override + String get zonedTimeDevice => '기기 시간'; + + @override + String get zonedTimeDeviceLoading => '기기 시간대를 확인하고 있어요.'; + + @override + String get zonedTimeDeviceUnavailable => '기기 시간대를 확인할 수 없어 환산 시각을 표시하지 못해요.'; + + @override + String get zonedTimeDeviceOutOfRange => + '기기 환산 날짜가 지원 범위를 벗어나요. 원래 약속 시간은 유지됩니다.'; + + @override + String get zonedTimeUnknownZone => '시간대를 확인해주세요.'; + + @override + String get zonedTimeHistoricalUnknownZone => + '이 기록의 시간대 규칙을 찾을 수 없어요. 저장된 약속은 그대로 보존됩니다.'; + + @override + String get zonedTimeHistoricalUncertain => + '과거 기록의 발생 시각을 확정할 수 없어요. 저장된 시간은 그대로 보존됩니다.'; + + @override + String get zonedTimeNonexistent => '이 시간대에는 존재하지 않는 시각이에요. 다른 시각을 선택해주세요.'; + + @override + String get zonedTimeAmbiguous => '두 번 발생하는 시각이에요. 사용할 발생 시각을 선택해주세요.'; + + @override + String get zonedTimeRulesChanged => '시간대 규칙이 달라졌어요. 변경할 발생 시각을 확인해주세요.'; + + @override + String get zonedTimeInvalid => '약속 시간을 확인해주세요.'; + + @override + String get zonedTimeInstant => '실제 시각 (UTC)'; + + @override + String get zonedTimeChooseZone => '시간대 선택'; + + @override + String get zonedTimeSearch => '도시 또는 시간대 검색'; + + @override + String get zonedTimeClearSearch => '검색어 지우기'; + + @override + String get zonedTimeCurrentDeviceZone => '현재 기기 시간대'; + + @override + String get zonedTimeCurrentSelection => '현재 일정 시간대'; + + @override + String get zonedTimeDraftPreview => '선택 미리보기'; + + @override + String get zonedTimeKeepCivil => + '날짜와 시각은 유지하고 시간대를 변경해요. 저장 전 약속 시간을 다시 확인합니다.'; + + @override + String get zonedTimeNoMatches => + '일치하는 시간대가 없어요. 도시 이름이나 IANA 식별자로 다시 검색해주세요.'; + + @override + String get zonedTimeApplyDraft => '선택 적용'; + + @override + String get zonedTimeReviewTitle => '약속 시간 확인'; + + @override + String get zonedTimeReviewDescription => '저장할 약속과 기기에서의 시각을 확인해주세요.'; + + @override + String get zonedTimeBefore => '변경 전'; + + @override + String get zonedTimeAfter => '저장할 약속'; + + @override + String get zonedTimeConfirmSave => '확인하고 저장'; + + @override + String get zonedTimeFirstOccurrence => '첫 번째 발생'; + + @override + String get zonedTimeSecondOccurrence => '두 번째 발생'; + + @override + String get zonedTimeDetectedZone => '기기에서 확인한 시간대'; + + @override + String get zonedTimeSelectedZone => '직접 선택한 시간대'; + + @override + String get zonedTimeSavedZone => '저장된 일정 시간대'; + + @override + String get zonedTimeSelectUnavailableZone => '기기 시간대를 확인할 수 없어요. 직접 선택해주세요.'; + + @override + String get zonedTimeNextValid => '다음 유효 시각 확인'; + + @override + String get zonedTimePreparationStart => '준비 시작'; + + @override + String get zonedCityAsiaSeoul => '서울'; + + @override + String get zonedCityAsiaTokyo => '도쿄'; + + @override + String get zonedCityAsiaShanghai => '상하이'; + + @override + String get zonedCityAsiaHongKong => '홍콩'; + + @override + String get zonedCityAsiaTaipei => '타이베이'; + + @override + String get zonedCityAsiaSingapore => '싱가포르'; + + @override + String get zonedCityAsiaBangkok => '방콕'; + + @override + String get zonedCityAsiaDubai => '두바이'; + + @override + String get zonedCityAsiaKolkata => '콜카타'; + + @override + String get zonedCityAsiaKathmandu => '카트만두'; + + @override + String get zonedCityEuropeLondon => '런던'; + + @override + String get zonedCityEuropeParis => '파리'; + + @override + String get zonedCityEuropeBerlin => '베를린'; + + @override + String get zonedCityEuropeRome => '로마'; + + @override + String get zonedCityEuropeMadrid => '마드리드'; + + @override + String get zonedCityEuropeMoscow => '모스크바'; + + @override + String get zonedCityAmericaNewYork => '뉴욕'; + + @override + String get zonedCityAmericaLosAngeles => '로스앤젤레스'; + + @override + String get zonedCityAmericaChicago => '시카고'; + + @override + String get zonedCityAmericaDenver => '덴버'; + + @override + String get zonedCityAmericaToronto => '토론토'; + + @override + String get zonedCityAmericaVancouver => '밴쿠버'; + + @override + String get zonedCityAmericaSaoPaulo => '상파울루'; + + @override + String get zonedCityAustraliaSydney => '시드니'; + + @override + String get zonedCityAustraliaMelbourne => '멜버른'; + + @override + String get zonedCityAustraliaPerth => '퍼스'; + + @override + String get zonedCityAustraliaLordHowe => '로드하우'; + + @override + String get zonedCityPacificAuckland => '오클랜드'; + + @override + String get zonedCityPacificHonolulu => '호놀룰루'; + + @override + String get zonedCityPacificApia => '아피아'; + + @override + String get zonedCityAfricaCairo => '카이로'; + + @override + String get zonedCityAfricaJohannesburg => '요하네스버그'; + + @override + String get zonedCityUtc => '협정 세계시'; + + @override + String get zonedTimeHomeDateBasis => '오늘 · 기기 날짜 기준'; + + @override + String get zonedTimeCalendarDateBasis => '달력 · 원래 약속 날짜 기준'; + + @override + String get zonedTimeDateBasisTitle => '날짜가 다른 이유'; + + @override + String get zonedTimeDateBasisDescription => + '홈의 오늘 일정은 현재 기기 시간대의 날짜로 모읍니다. 월 달력은 각 약속의 원래 시간대와 날짜에 배치합니다. 약속과 기기 시간대가 다르면 같은 순간이 서로 다른 날짜일 수 있어 두 날짜와 시간대를 함께 표시합니다.'; + + @override + String get zonedTimeAllReviewedOccurrences => '검토 중인 회차 모두 보기'; + + @override + String get zonedTimeReviewScopeOccurrence => '변경 범위: 이번 회차'; + + @override + String get zonedTimeReviewScopeFollowing => '변경 범위: 이번 회차와 이후'; + + @override + String get zonedTimeReviewScopeNew => '새 반복 일정'; + + @override + String get zonedTimePreparationUnavailable => + '현재 시간대 규칙으로 준비 시작을 표시할 수 없어요. 저장 전에 다시 검토해주세요.'; + + @override + String get homeNextAppointment => '다음 약속'; + + @override + String get homePreparationInProgress => '진행 중인 준비'; + + @override + String get homePreparationPrompt => '준비할 약속'; + + @override + String get homePreviouslyCheckedAppointment => '이전에 확인한 약속'; + + @override + String get homeCheckingAppointments => '다음 약속을 확인하고 있어요.'; + + @override + String get homeNoUpcomingAppointments => '예정된 약속이 없어요.'; + + @override + String get homeQueryFailed => '약속을 확인하지 못했어요. 다시 시도해 주세요.'; + + @override + String get homeQueryCancelled => '약속 확인을 중단했어요. 캘린더에서 약속을 확인할 수 있어요.'; + + @override + String get homeQueryInterrupted => '약속 확인이 중단됐어요. 이어서 확인해 주세요.'; + + @override + String get homeQueryLimited => '모든 약속을 확인하지 못했어요. 캘린더에서 약속이나 반복 설정을 확인해 주세요.'; + + @override + String get homeQueryStale => + '아래는 이전에 확인한 내용이에요. 다시 확인하기 전에는 이 카드에서 준비를 시작할 수 없어요.'; + + @override + String get homeRetryQuery => '다시 확인'; + + @override + String get homeContinueQuery => '이어서 확인'; + + @override + String get homeCancelQuery => '확인 중단'; + + @override + String get homeTimeIssues => + '시간을 확인해야 하는 약속이 있어요. 캘린더에서 해당 약속의 날짜와 시간대를 검토해 주세요.'; + + @override + String get homeReviewCalendar => '캘린더에서 확인'; + + @override + String get scheduleDeletionRemovedTitle => '일정 이력이 삭제됐어요'; + + @override + String get scheduleDeletionComplete => '일정 이력과 알림 정리가 완료됐어요.'; + + @override + String get scheduleDeletionCleaning => '이력은 삭제됐고 알림을 정리하고 있어요.'; + + @override + String get scheduleDeletionPending => + '이력은 삭제됐지만 알림 정리가 아직 확인되지 않았어요. 정리가 끝나면 다시 확인할 수 있어요.'; + + @override + String get scheduleDeletionPreparationActive => + '준비가 진행 중이에요. 준비 화면에서 먼저 종료한 뒤 삭제해 주세요.'; + + @override + String get scheduleDeletionConsequences => + '선택한 일정의 이름, 메모, 개별 준비와 결과를 삭제해요. 다른 일정과 공유 중인 내용 및 이미 반영된 시간 엄수 점수는 유지돼요. 앱에서 실행 취소할 수 없으며, 이전에 내보낸 백업을 복원하면 이력이 다시 나타날 수 있어요.'; + + @override + String get scheduleDeletionFollowing => '선택한 회차와 이후의 미진행 회차를 삭제해요.'; + + @override + String get scheduleDeletionOnlySelected => + '선택한 일정만 삭제해요. 다른 일정과 반복 규칙은 유지돼요.'; + + @override + String get scheduleDeletionAlreadyAbsent => + '이 일정은 현재 데이터에 이미 없어요. 새로 삭제한 데이터는 없어요.'; + + @override + String get scheduleDeletionRetryCleanup => '알림 정리 다시 확인'; + + @override + String get scheduleDeletionAbsentTitle => '이미 없는 일정'; + + @override + String get scheduleDeletionFollowingConsequences => + '이 반복 일정의 선택한 회차부터 이후 미진행 회차를 삭제하고, 이후 반복 생성을 종료해요. 개별로 수정한 이후 회차도 삭제 대상에 포함돼요. 삭제 대상의 이름, 메모와 다른 일정에서 공유하지 않는 준비 내용을 삭제해요. 과거 이력, 진행 중이거나 준비가 고정된 회차, 다른 반복 일정, 공유 중인 내용과 이미 반영된 시간 엄수 점수는 유지돼요. 앱에서 실행 취소할 수 없어요. 이미 내보낸 백업 파일은 바뀌지 않으며, 이전 백업을 복원하면 삭제한 회차와 반복이 다시 나타날 수 있어요.'; } diff --git a/lib/main.dart b/lib/main.dart index 27c36f95..7c0cbc16 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,14 @@ +import 'package:on_time_front/core/database/recovery/recovery_restore_service.dart'; +import 'package:on_time_front/core/database/recovery/pair_files.dart'; +import 'package:on_time_front/core/database/initial_store_guard.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'package:on_time_front/core/database/restore_delivery_cleanup.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; import 'package:on_time_front/core/database/bootstrap_privacy_boundary.dart'; import 'package:on_time_front/core/services/runtime_privacy_migration.dart'; import 'package:flutter/material.dart'; @@ -18,35 +29,111 @@ import 'package:on_time_front/presentation/startup/screens/local_startup_gate.da void main() async { WidgetsFlutterBinding.ensureInitialized(); AppLogger.configureFlutterDebugPrint(); + StartupDependencyScope? dependencies; + RecoveryRestoreService? recovery; + Future recoveryService() async => + recovery ??= RecoveryRestoreService( + await PairFiles.device(), + repairCutover: LocalDataLifecycle.repairCutoverAfterVerifiedRestore, + ); + final stage = ValueNotifier(StartupStage.locale); + Future runStage(StartupStage next, Future Function() action) { + stage.value = next; + return startupStage(next, action); + } + runApp( LocalStartupGate( - prepare: () async { - await HardwareKeyboard.instance.syncKeyboardState().catchError((_) {}); - await initializeDateFormatting(); - // Recovery owns its own cleanup evidence. A failed reset must not fall - // into an unrelated best-effort cleanup that discards its registry. - await bootstrapWithPrivacyCleanup( - bootstrap: LocalDataLifecycle.bootstrap, - cleanup: RuntimePrivacyMigration.clearLegacyWithoutDatabase, - shouldCleanup: (error) => error is! LocalResetRecoveryRequired, - ); + stage: stage, + recovery: recoveryService, + prepare: () { + final owner = dependencies ??= StartupDependencyScope(getIt); + return owner.track(() async { + await HardwareKeyboard.instance.syncKeyboardState().catchError( + (_) {}, + ); + await runStage(StartupStage.locale, initializeDateFormatting); + // Recovery owns its own cleanup evidence. A failed reset must not fall + // into an unrelated best-effort cleanup that discards its registry. + await runStage( + StartupStage.lifecycle, + () => bootstrapWithPrivacyCleanup( + bootstrap: () => LocalDataLifecycle.bootstrap( + preparePairs: () async => + (await recoveryService()).prepareStartup(), + ), + cleanup: RuntimePrivacyMigration.clearLegacyWithoutDatabase, + shouldCleanup: (error) => + error is! LocalResetRecoveryRequired && + error is! PairRecoveryRequired, + ), + ); + // The reset/cutover boundary above must complete before opening a DB. + await runStage( + StartupStage.temporaryFiles, + RestoreStaging.cleanupAbandoned, + ); + final keys = InstallationKeyStore(); + final database = AppDatabase(keys); + try { + await runStage( + StartupStage.store, + () => RestoreRuntimeIdentity.shared.prepareStartup( + database, + LocalDataOperationGate.shared, + cleanupPlatform: () => + cleanupRestoreDeliveries(AlarmOperationCoordinator.shared), + ), + ); + } finally { + await StartupDependencyScope.releaseChecked( + database, + database.close, + ); + } + await runStage(StartupStage.store, () async { + final pair = await (await PairFiles.device()).selected(); + if (pair.isLegacy) { + await (await InitialStoreGuard.device( + keys, + )).completeVerifiedCreation(); + } else { + await LocalDataLifecycle.repairCutoverAfterVerifiedRestore(); + } + }); + }); + }, + cleanupAttempt: () async { + stage.value = StartupStage.cleanup; + await dependencies?.cleanup(); + dependencies = null; }, + beginReset: LocalResetWorkflow( + RecoveryResetAdapter(LocalDataLifecycle.beginReset), + ).call, retryReset: LocalResetWorkflow( RecoveryResetAdapter(LocalDataLifecycle.resumeReset), ).call, ready: () { - configureDependencies(); - NotificationService.instance.configureDelegate( - notificationTapRouter: getIt.get(), - ); - AppLogger.debug( - 'Device standalone mode=${DeviceInfoService.isInStandaloneMode}', - ); - return ResetAwareApp( - gate: LocalDataOperationGate.shared, - reset: () => getIt()(), - child: const App(), - ); + stage.value = StartupStage.dependencies; + try { + return dependencies!.configure(() { + configureDependencies(); + NotificationService.instance.configureDelegate( + notificationTapRouter: getIt.get(), + ); + AppLogger.debug( + 'Device standalone mode=${DeviceInfoService.isInStandaloneMode}', + ); + return ResetAwareApp( + gate: LocalDataOperationGate.shared, + reset: () => getIt()(), + child: const App(), + ); + }); + } catch (error) { + throw StartupFailure(StartupStage.dependencies, error); + } }, ), ); diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index 87485998..12c80d56 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -1,3 +1,11 @@ +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; +export 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/device_civil_day.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/core/startup/subscription_cleanup.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'dart:async'; @@ -76,7 +84,36 @@ class ScheduleBloc extends Bloc { _registerHandlers(); } + int _dataGeneration = LocalDataOperationGate.shared.generation; + void _onDataGateChanged() { + final gate = LocalDataOperationGate.shared; + if (_dataGeneration != gate.generation) { + _dataGeneration = gate.generation; + _notificationRevision++; + _scheduleStartTimer?.cancel(); + _stopPreparationTimer(); + _clearActivePreparationRun(); + _activeEarlyStartScheduleId = null; + _currentScheduleId = null; + _notificationPromptOwner = null; + _notificationPreparationOwner = null; + _notificationPreparationId = null; + _attemptedSteps.clear(); + _lastSnapshotSavedAt = null; + _subscriptionRevision++; + _retiredWatches.retire(_upcomingScheduleSubscription); + _upcomingScheduleSubscription = null; + if (!isClosed) add(const _NearestProjectionInvalidated(replaced: true)); + } + if (!isClosed && gate.isAvailable) { + add(const ScheduleSubscriptionRequested()); + } + } + + final _retiredWatches = SubscriptionCleanup(); void _registerHandlers() { + StartupDependencyScope.own(this, close); + LocalDataOperationGate.shared.addListener(_onDataGateChanged); on<_NotificationPromptPresented>((event, emit) { if (!identical(_notificationPromptOwner, event.owner) || !event.isCurrent()) { @@ -98,6 +135,88 @@ class ScheduleBloc extends Bloc { }); on(_onSubscriptionRequested); on(_onUpcomingReceived); + on<_OwnedScheduleUpcomingReadFailed>(_onUpcomingReadFailed); + on<_NearestQueryReceived>(_onNearestQueryReceived); + on<_NearestProjectionInvalidated>((event, emit) { + if (event.replaced) { + emit(const ScheduleState.initial()); + } else { + final key = state.nearestQuery.key; + emit( + state.copyWith( + nearestQuery: key == null + ? const NearestQueryIdle() + : NearestQueryLimited( + key: key, + reason: NearestQueryLimitReason.interrupted, + progress: const NearestQueryProgress( + visitedCandidates: 0, + candidateBudget: 200000, + provenSegments: 0, + totalSegments: 0, + ), + canContinue: false, + canRetry: true, + stale: state.freshNearest ?? state.staleNearest, + ), + ), + ); + } + }); + on<_NotificationPreparationOwnershipChanged>((event, emit) { + emit( + state.copyWith( + hasNotificationPreparationOwner: notificationPreparationId != null, + ), + ); + }); + on((event, emit) { + final query = state.nearestQuery; + if (query.key != event.queryKey) return; + if (query is NearestQueryError || + query is NearestQueryLimited && query.canRetry) { + add(const ScheduleSubscriptionRequested()); + } + }); + on((event, emit) { + final query = state.nearestQuery; + if (query.key == event.queryKey && + query is NearestQueryLimited && + query.canContinue) { + _queryPaused = false; + _upcomingScheduleSubscription?.resume(); + emit( + state.copyWith( + nearestQuery: NearestQueryLoading( + key: event.queryKey, + progress: query.progress, + stale: query.stale, + issues: query.issues, + ), + ), + ); + } + }); + on((event, emit) { + final query = state.nearestQuery; + if (query.key != event.queryKey || query is! NearestQueryLoading) return; + _queryPaused = true; + _upcomingScheduleSubscription?.pause(); + _retireBoundaryIntent(); + emit( + state.copyWith( + nearestQuery: NearestQueryLimited( + key: event.queryKey, + reason: NearestQueryLimitReason.cancelled, + progress: query.progress, + canContinue: true, + canRetry: false, + stale: query.stale, + issues: query.issues, + ), + ), + ); + }); on(_onAlarmPromptRequested); on(_onScheduleStarted); on(_onPreparationStarted); @@ -115,8 +234,7 @@ class ScheduleBloc extends Bloc { final SchedulePreparationSessionUseCase _schedulePreparationSessionUseCase; final NowProvider _nowProvider; final NotifyPreparationStep _notifyPreparationStep; - StreamSubscription? - _upcomingScheduleSubscription; + StreamSubscription? _upcomingScheduleSubscription; Timer? _scheduleStartTimer; String? _currentScheduleId; String? _activeEarlyStartScheduleId; @@ -149,8 +267,45 @@ class ScheduleBloc extends Bloc { void observeLifecycleState(AppLifecycleState phase) { _stepObservation = null; _stepRevision++; + _queryForeground = phase == AppLifecycleState.resumed; + _retireBoundaryIntent(); + _queryMidnightTimer?.cancel(); + _queryDeviceClockTimer?.cancel(); + _queryDeviceClockTimer = null; + _queryClockObservation = null; + if (!_queryForeground) { + _subscriptionRevision++; + _retiredWatches.retire(_upcomingScheduleSubscription); + _upcomingScheduleSubscription = null; + if (!isClosed) add(const _NearestProjectionInvalidated()); + } else if (LocalDataOperationGate.shared.isAvailable && !isClosed) { + add(const ScheduleSubscriptionRequested()); + } + } + + bool _queryForeground = true; + bool _queryPaused = false; + int _queryEpoch = 0; + (DateTime, Duration)? _queryClockObservation; + _BoundaryStartIntent? _boundaryStartIntent; + void _retireBoundaryIntent() { + _boundaryStartIntent = null; + _scheduleStartTimer?.cancel(); + _scheduleStartTimer = null; } + void observeDeviceTimeZone(String zone) { + if (_observedDeviceTimeZone == zone) return; + final previous = _observedDeviceTimeZone; + _observedDeviceTimeZone = zone; + if (previous != null && _queryForeground && !isClosed) { + _retireBoundaryIntent(); + add(const ScheduleSubscriptionRequested()); + } + } + + String? _observedDeviceTimeZone; + Object? _notificationPreparationOwner; Object? _notificationPreparationViewOwner; Object? get notificationPreparationOwner => @@ -171,6 +326,7 @@ class ScheduleBloc extends Bloc { _notificationPreparationGeneration = LocalDataOperationGate.shared.generation; releaseNotificationPrompt(owner, resumeNearest: false); + if (!isClosed) add(const _NotificationPreparationOwnershipChanged()); } void attachNotificationPreparation(Object owner, Object viewOwner) { @@ -188,6 +344,7 @@ class ScheduleBloc extends Bloc { _notificationPreparationViewOwner = null; _notificationPreparationId = null; _notificationPreparationGeneration = null; + if (!isClosed) add(const _NotificationPreparationOwnershipChanged()); _notificationRevision++; if (!isClosed && _notificationPromptOwner == null) { add(const ScheduleSubscriptionRequested()); @@ -202,6 +359,9 @@ class ScheduleBloc extends Bloc { final generation = LocalDataOperationGate.shared.generation; return () => !isClosed && + !LocalDataOperationGate.shared.isReplacingData && + !LocalDataOperationGate.shared.isRecoveryPending && + !LocalDataOperationGate.shared.isInvalidated && _notificationPromptOwner == null && revision == _notificationRevision && generation == LocalDataOperationGate.shared.generation; @@ -224,6 +384,7 @@ class ScheduleBloc extends Bloc { _notificationPreparationViewOwner = null; _notificationPreparationId = null; _notificationPreparationGeneration = null; + if (!isClosed) add(const _NotificationPreparationOwnershipChanged()); _notificationRevision++; _notificationPromptOwner = owner; add(_NotificationPromptPresented(schedule, owner, isCurrent)); @@ -240,24 +401,278 @@ class ScheduleBloc extends Bloc { } int _subscriptionRevision = 0; + int _upcomingReadRevision = 0; + int _upcomingFailureRevision = 0; + Timer? _queryMidnightTimer; + Timer? _queryDeviceClockTimer; + (int, int, int, Duration, String)? _queryDeviceDay; + + (int, int, int, Duration, String) _deviceDaySignature() { + final local = _nowProvider().toLocal(); + return ( + local.year, + local.month, + local.day, + local.timeZoneOffset, + local.timeZoneName, + ); + } + + void _watchQueryDay() { + if (!_queryForeground) return; + _queryDeviceDay = _deviceDaySignature(); + _queryClockObservation = (_nowProvider().toUtc(), _monotonicNow); + _queryMidnightTimer?.cancel(); + final now = _nowProvider().toUtc(); + _queryMidnightTimer = Timer( + DeviceCivilDay.at(now).endUtc.difference(now), + () { + if (_queryForeground && + !isClosed && + LocalDataOperationGate.shared.isAvailable) { + _retireBoundaryIntent(); + add(const ScheduleSubscriptionRequested()); + } + }, + ); + _queryDeviceClockTimer ??= Timer.periodic(const Duration(minutes: 1), (_) { + if (!_queryForeground || + isClosed || + !LocalDataOperationGate.shared.isAvailable) { + return; + } + final wall = _nowProvider().toUtc(); + final mono = _monotonicNow; + final before = _queryClockObservation; + final drift = + before != null && + (wall.difference(before.$1) - (mono - before.$2)).inMicroseconds + .abs() > + stepClockTolerance.inMicroseconds; + _queryClockObservation = (wall, mono); + if (drift || _queryDeviceDay != _deviceDaySignature()) { + _retireBoundaryIntent(); + add(const ScheduleSubscriptionRequested()); + } + }); + } Future _onSubscriptionRequested( ScheduleSubscriptionRequested event, Emitter emit, ) async { final revision = ++_subscriptionRevision; + final generation = LocalDataOperationGate.shared.generation; + _queryPaused = false; + _retireBoundaryIntent(); final previous = _upcomingScheduleSubscription; _upcomingScheduleSubscription = null; - await previous?.cancel(); - if (isClosed || revision != _subscriptionRevision) return; - - _upcomingScheduleSubscription = _getNearestUpcomingScheduleUseCase().listen( - (upcomingSchedule) { - // ✅ Safety check: Only add events if bloc is still active - if (!isClosed) { - add(ScheduleUpcomingReceived(upcomingSchedule)); + await _retiredWatches.cancelAndWait(previous); + if (isClosed || + !_queryForeground || + revision != _subscriptionRevision || + !LocalDataOperationGate.shared.isAvailable) { + return; + } + final key = NearestQueryKey( + generation: generation, + epoch: ++_queryEpoch, + revision: 0, + ); + // Existing durable preparation is independent of the bounded future search. + // Recover it even when that search will stop at an error or budget limit. + if (_notificationPromptOwner == null && + notificationPreparationId == null && + _explicitStartPending == null) { + try { + final active = await _getNearestUpcomingScheduleUseCase.readActive(); + if (isClosed || + emit.isDone || + revision != _subscriptionRevision || + generation != LocalDataOperationGate.shared.generation || + !_queryForeground) { + return; } - }, + if (active != null) { + await _onUpcomingReceived( + _OwnedScheduleUpcomingReceived(active, generation, revision), + emit, + ); + if (isClosed || + emit.isDone || + revision != _subscriptionRevision || + generation != LocalDataOperationGate.shared.generation || + !_queryForeground) { + return; + } + } + } catch (_) { + if (!isClosed && + !emit.isDone && + revision == _subscriptionRevision && + generation == LocalDataOperationGate.shared.generation) { + emit( + state.copyWith( + nearestQuery: NearestQueryError( + key: key, + reason: NearestQueryFailureReason.preparationReadFailed, + stale: state.freshNearest ?? state.staleNearest, + ), + ), + ); + } + } + } + // A rejected old active read is as stale as a successful old receipt. + // In particular it cannot overwrite the handle of a newer subscription. + if (isClosed || + emit.isDone || + !_queryForeground || + revision != _subscriptionRevision || + generation != LocalDataOperationGate.shared.generation || + !LocalDataOperationGate.shared.isAvailable) { + return; + } + _upcomingScheduleSubscription = _getNearestUpcomingScheduleUseCase(key: key) + .listen( + (query) { + if (!isClosed && + revision == _subscriptionRevision && + generation == LocalDataOperationGate.shared.generation && + LocalDataOperationGate.shared.isAvailable) { + add(_NearestQueryReceived(query, revision)); + } + }, + onError: (Object error, StackTrace stack) { + if (!isClosed && + revision == _subscriptionRevision && + generation == LocalDataOperationGate.shared.generation) { + add(_OwnedScheduleUpcomingReadFailed(generation, revision)); + } + }, + ); + _watchQueryDay(); + } + + Future _onNearestQueryReceived( + _NearestQueryReceived event, + Emitter emit, + ) async { + bool current() => + !isClosed && + !emit.isDone && + !_queryPaused && + _queryForeground && + event.subscriptionRevision == _subscriptionRevision && + event.query.key?.generation == + LocalDataOperationGate.shared.generation && + LocalDataOperationGate.shared.isAvailable; + if (!current()) return; + final read = ++_upcomingReadRevision; + var query = event.query; + final old = state.freshNearest ?? state.staleNearest; + if (query is NearestQueryLoading && + query.stale == null && + old != null && + old.authority.key.generation == query.key!.generation) { + query = NearestQueryLoading( + key: query.key!, + progress: query.progress, + stale: old, + issues: query.issues, + ); + } + _retireBoundaryIntent(); + emit(state.copyWith(nearestQuery: query)); + if (query is NearestQueryLoading || query is NearestQueryIdle) return; + if (_notificationPromptOwner != null || + notificationPreparationId != null || + _explicitStartPending != null) { + return; + } + ScheduleWithPreparationEntity? active; + try { + active = await _getNearestUpcomingScheduleUseCase.readActive(); + } catch (_) { + if (current() && read == _upcomingReadRevision) { + emit( + state.copyWith( + nearestQuery: NearestQueryError( + key: query.key!, + reason: NearestQueryFailureReason.preparationReadFailed, + stale: query is NearestQueryReady ? query.value : query.stale, + ), + ), + ); + } + return; + } + if (!current() || read != _upcomingReadRevision) return; + if (active != null) { + await _onUpcomingReceived( + _OwnedScheduleUpcomingReceived( + active, + query.key!.generation, + event.subscriptionRevision, + ), + emit, + ); + if (current() && _upcomingReadRevision == read + 1) { + emit(state.copyWith(nearestQuery: query)); + } + return; + } + // An authoritative active read may retire a deleted/completed run. Query + // refresh itself never writes completion or clears persisted runtime data. + _stopPreparationTimer(); + _clearActivePreparationRun(); + _activeEarlyStartScheduleId = null; + _currentScheduleId = query is NearestQueryReady + ? query.value.schedule.id + : null; + if (query is NearestQueryReady) { + _snapshotInvalidated = false; + emit( + ScheduleState.upcoming( + query.value.schedule, + ).copyWith(nearestQuery: query), + ); + _startScheduleTimer(query.value.schedule); + } else { + emit( + (query is NearestQueryEmpty + ? const ScheduleState.notExists() + : const ScheduleState.initial()) + .copyWith(nearestQuery: query), + ); + } + } + + void _onUpcomingReadFailed( + _OwnedScheduleUpcomingReadFailed event, + Emitter emit, + ) { + if (event.subscriptionRevision != _subscriptionRevision || + event.generation != LocalDataOperationGate.shared.generation) { + return; + } + _upcomingReadRevision++; + _upcomingFailureRevision++; + _retireBoundaryIntent(); + emit( + state.copyWith( + nearestQuery: NearestQueryError( + key: + state.nearestQuery.key ?? + NearestQueryKey( + generation: event.generation, + epoch: _queryEpoch, + revision: event.subscriptionRevision, + ), + reason: NearestQueryFailureReason.storeReadFailed, + stale: state.freshNearest ?? state.staleNearest, + ), + ), ); } @@ -265,30 +680,77 @@ class ScheduleBloc extends Bloc { ScheduleUpcomingReceived event, Emitter emit, ) async { + if (event is _OwnedScheduleUpcomingReceived && + event.subscriptionRevision != _subscriptionRevision) { + return; + } + if (event.generation != null && + event.generation != LocalDataOperationGate.shared.generation) { + return; + } if (notificationPreparationId != null || _explicitStartPending != null) { return; } + final backgroundCurrent = _captureBackgroundValidity(); + final subscriptionRevision = _subscriptionRevision; + final readRevision = ++_upcomingReadRevision; + bool current() => + backgroundCurrent() && + subscriptionRevision == _subscriptionRevision && + readRevision == _upcomingReadRevision; + if (!current()) return; + final now = _nowProvider(); + var candidate = event.upcomingSchedule; + if (candidate != null && candidate.timeResolution == null) { + candidate = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + candidate, + candidate.preparation, + timeResolution: ScheduleTimeResolver.resolve( + candidate, + nowUtc: now, + ), + ); + } + if (candidate != null && + (candidate.timeResolution ?? + ScheduleTimeResolver.resolve(candidate, nowUtc: now)) + .instantUtc == + null) { + // An unresolved appointment remains available in calendar/edit surfaces. + // It must not enter countdown/start/finish automation or crash other rows. + _scheduleStartTimer?.cancel(); + _scheduleStartTimer = null; + _stopPreparationTimer(); + _clearActivePreparationRun(); + if (_captureBackgroundValidity()()) { + emit(const ScheduleState.notExists()); + } + return; + } // A repository emission is not a successful recovery receipt. Preserve // the live projection and pending warning for the same unchanged run. if (state.hasPendingStartRecovery && - event.upcomingSchedule?.id == state.schedule?.id && - event.upcomingSchedule?.cacheFingerprint == - state.schedule?.cacheFingerprint && - event.upcomingSchedule != null && - !_isEnded(event.upcomingSchedule!.doneStatus)) { + candidate?.id == state.schedule?.id && + candidate?.cacheFingerprint == state.schedule?.cacheFingerprint && + candidate != null && + !_isEnded(candidate.doneStatus)) { return; } - final current = _captureBackgroundValidity(); - if (!current()) return; _stepRevision++; _stepObservation = null; _scheduleStartTimer?.cancel(); _scheduleStartTimer = null; - final now = _nowProvider(); - - if (event.upcomingSchedule == null || - event.upcomingSchedule!.scheduleTime.isBefore(now)) { - final staleId = event.upcomingSchedule?.id ?? _currentScheduleId; + final hasDurableRun = + candidate != null && + candidate.isStarted && + candidate.startedAt != null && + candidate.preparationFrozen && + candidate.doneStatus == ScheduleDoneStatus.notEnded; + if (candidate == null || + (candidate.occurrenceInstantUtc.isBefore(now.toUtc()) && + !hasDurableRun)) { + final staleId = candidate?.id ?? _currentScheduleId; if (staleId != null) { await _clearPersistedState(staleId); if (!current()) return; @@ -303,7 +765,16 @@ class ScheduleBloc extends Bloc { return; } - final incoming = event.upcomingSchedule!; + final incoming = candidate; + if (incoming.requiresStartConfirmation) { + _currentScheduleId = incoming.id; + _activeEarlyStartScheduleId = null; + _clearActivePreparationRun(); + _stopPreparationTimer(); + _snapshotInvalidated = true; + emit(ScheduleState.upcoming(incoming)); + return; + } if (_currentScheduleId != null && _currentScheduleId != incoming.id) { await _clearPersistedState(_currentScheduleId!); if (!current()) return; @@ -329,6 +800,43 @@ class ScheduleBloc extends Bloc { !_activePreparationRunStartedAt!.isAtSameMomentAs( incoming.preparationStartTime, )); + if (hasDurableRun) { + final restoredStart = _activePreparationRunStartedAt; + if (_snapshotInvalidated || + restoredStart == null || + !restoredStart.isAtSameMomentAs(incoming.startedAt!)) { + // A durable start marker does not reconstruct a missing or different + // runtime session. Keep access without inventing elapsed progress or + // triggering another automatic start. + _snapshotInvalidated = true; + _activeEarlyStartScheduleId = null; + _clearActivePreparationRun(); + _stopPreparationTimer(); + resolvedSchedule = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + incoming.copyWith(requiresStartConfirmation: true), + incoming.preparation, + timeResolution: + incoming.timeResolution ?? + ScheduleTimeResolver.resolve(incoming, nowUtc: now), + ); + emit(ScheduleState.upcoming(resolvedSchedule)); + return; + } + final early = !restoredStart.isAtSameMomentAs( + incoming.preparationStartTime, + ); + _activeEarlyStartScheduleId = early ? incoming.id : null; + emit( + ScheduleState.started( + resolvedSchedule, + isEarlyStarted: early, + isResumedPreparation: true, + ), + ); + _startPreparationTimer(); + return; + } if (!_snapshotInvalidated && !hasEarlyStartSession && incoming.preparationStartTime.isAfter(now)) { @@ -359,6 +867,9 @@ class ScheduleBloc extends Bloc { actionEvents: const [], now: now, ), + timeResolution: + resolvedSchedule.timeResolution ?? + ScheduleTimeResolver.resolve(resolvedSchedule, nowUtc: now), ); } await _startScheduleLocally(resolvedSchedule.id); @@ -371,27 +882,6 @@ class ScheduleBloc extends Bloc { } _activeEarlyStartScheduleId = null; - if (_isAtPreparationStartBoundary(resolvedSchedule, now)) { - emit(ScheduleState.upcoming(resolvedSchedule)); - AppLogger.debug( - 'preparation boundary reached scheduleId=${resolvedSchedule.id}', - ); - add(const ScheduleStarted()); - return; - } - - if (_isPreparationOnGoing(resolvedSchedule, now)) { - await _startScheduleLocally(resolvedSchedule.id); - if (!current()) return; - emit(ScheduleState.ongoing(resolvedSchedule)); - AppLogger.debug( - 'ongoing scheduleId=${resolvedSchedule.id} ' - 'currentStepId=${resolvedSchedule.preparation.currentStep?.id}', - ); - _startPreparationTimer(); - return; - } - _stopPreparationTimer(); emit(ScheduleState.upcoming(resolvedSchedule)); AppLogger.debug('upcoming scheduleId=${resolvedSchedule.id}'); @@ -402,17 +892,73 @@ class ScheduleBloc extends Bloc { ScheduleStarted event, Emitter emit, ) async { - final current = _captureBackgroundValidity(); + final intent = event._boundaryIntent; + if (intent == null || !_isBoundaryIntentCurrent(intent)) return; + final backgroundCurrent = _captureBackgroundValidity(); + final failureRevision = _upcomingFailureRevision; + bool current() => + backgroundCurrent() && + failureRevision == _upcomingFailureRevision && + !state.hasUpcomingReadFailure && + _isBoundaryIntentCurrent(intent); if (!current()) return; - if (_snapshotInvalidated) return; + if (state.hasUpcomingReadFailure || + _snapshotInvalidated || + (state.schedule?.requiresStartConfirmation ?? false)) { + return; + } if (state.schedule != null && state.schedule!.id == _currentScheduleId) { if (_activeEarlyStartScheduleId == _currentScheduleId) return; AppLogger.debug('schedule started scheduleId=${state.schedule!.id}'); - await _startScheduleLocally(state.schedule!.id); - if (!current()) return; - emit(ScheduleState.started(state.schedule!)); - _navigationService.push('/scheduleStart'); - _activePreparationRunStartedAt ??= state.schedule!.preparationStartTime; + final schedule = state.schedule!; + final interpretation = state.freshNearest!.resolution; + late final DateTime committedAt; + try { + committedAt = await _schedulePreparationSessionUseCase + .startSchedulePreparation( + state.schedule!.id, + isCurrent: current, + expectedFingerprint: intent.fingerprint, + ); + } on ScheduleStartRejected { + if (identical(_boundaryStartIntent, intent)) _retireBoundaryIntent(); + return; + } on AlarmOperationInvalidated { + if (identical(_boundaryStartIntent, intent)) _retireBoundaryIntent(); + return; + } + // The committed writer receipt owns this run even when its own database + // emission has already invalidated the recommendation query. It does not + // grant fresh query authority or replace a separately owned prompt. + if (!backgroundCurrent()) return; + final committed = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule.copyWith( + isStarted: true, + startedAt: committedAt, + preparationFrozen: true, + requiresStartConfirmation: false, + ), + schedule.preparation, + timeResolution: interpretation, + ); + _currentScheduleId = committed.id; + _activeEarlyStartScheduleId = null; + _activePreparationRunStartedAt = committedAt; + _activePreparationActionEvents = const []; + _snapshotInvalidated = false; + _retireBoundaryIntent(); + emit( + state.copyWith( + status: ScheduleStatus.started, + schedule: committed, + isEarlyStarted: false, + isResumedPreparation: false, + ), + ); + if (_queryForeground) { + _navigationService.push('/scheduleStart'); + } _startPreparationTimer(); } } @@ -427,7 +973,8 @@ class ScheduleBloc extends Bloc { 'alarm prompt requested: scheduleId=${event.scheduleId} ' 'startPreparation=${event.startPreparation}', ); - final cachedSchedule = event.startPreparation + final cachedSchedule = + event.startPreparation && !state.hasUpcomingReadFailure ? _matchingCachedAlarmSchedule(event) : null; if (cachedSchedule != null) { @@ -554,7 +1101,14 @@ class ScheduleBloc extends Bloc { _notificationRevision == revision && state.schedule?.id == schedule?.id && (event.isCurrent?.call() ?? _notificationPromptOwner == null); - if (schedule == null || !current()) { + final awaitingFreshQuery = + state.nearestQuery is! NearestQueryIdle && + state.freshNearest == null && + !state.hasOwnedPreparationSurface; + if (schedule == null || + !current() || + state.hasUpcomingReadFailure || + awaitingFreshQuery) { event.receipt?.complete(null); return; } @@ -563,6 +1117,18 @@ class ScheduleBloc extends Bloc { event.receipt == null) { return; } + final interpretation = + schedule.timeResolution ?? + (state.freshNearest?.schedule.id == schedule.id + ? state.freshNearest!.resolution + : ScheduleTimeResolver.resolve( + schedule, + nowUtc: _nowProvider().toUtc(), + )); + if (interpretation.instantUtc == null) { + event.receipt?.complete(null); + return; + } final pending = Object(); _explicitStartPending = pending; _currentScheduleId = schedule.id; @@ -584,9 +1150,21 @@ class ScheduleBloc extends Bloc { _activeEarlyStartScheduleId = schedule.id; _activePreparationRunStartedAt = result.startedAt; _activePreparationActionEvents = result.actionEvents; + final committed = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule.copyWith( + isStarted: true, + startedAt: result.durableStartedAt ?? result.startedAt, + preparationFrozen: true, + requiresStartConfirmation: false, + ), + schedule.preparation, + timeResolution: interpretation, + ); emit( state.copyWith( status: ScheduleStatus.started, + schedule: committed, isEarlyStarted: true, hasPendingStartRecovery: result.hasPendingRecovery, ), @@ -664,7 +1242,9 @@ class ScheduleBloc extends Bloc { SchedulePreparationTimeRefreshRequested event, Emitter emit, ) async { - if (_finishing || + if (LocalDataOperationGate.shared.isRecoveryPending || + LocalDataOperationGate.shared.isReplacingData || + _finishing || _notificationPromptOwner != null || state.schedule == null) { return; @@ -686,6 +1266,9 @@ class ScheduleBloc extends Bloc { ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( state.schedule!, refreshedPreparation, + timeResolution: + state.schedule!.timeResolution ?? + ScheduleTimeResolver.resolve(state.schedule!, nowUtc: observedAt), ); emit(state.copyWith(schedule: refreshedSchedule)); _observeStepTransition( @@ -704,7 +1287,9 @@ class ScheduleBloc extends Bloc { ScheduleStepSkipped event, Emitter emit, ) async { - if (_finishing || + if (LocalDataOperationGate.shared.isRecoveryPending || + LocalDataOperationGate.shared.isReplacingData || + _finishing || _notificationPromptOwner != null || state.schedule == null) { return; @@ -737,6 +1322,9 @@ class ScheduleBloc extends Bloc { ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( state.schedule!, updated, + timeResolution: + state.schedule!.timeResolution ?? + ScheduleTimeResolver.resolve(state.schedule!, nowUtc: now), ); emit(state.copyWith(schedule: newSchedule)); _stepObservation = (_monotonicNow, now, _lifecycle); @@ -797,23 +1385,52 @@ class ScheduleBloc extends Bloc { } void _startScheduleTimer(ScheduleWithPreparationEntity schedule) { - final now = _nowProvider(); - final target = schedule.preparationStartTime; - if (!target.isAfter(now)) { - if (!isClosed && _currentScheduleId == schedule.id) { - add(const ScheduleStarted()); - } + _retireBoundaryIntent(); + final ready = state.freshNearest; + final now = _nowProvider().toUtc(); + final target = schedule.preparationStartTime.toUtc(); + if (!_queryForeground || + schedule.requiresStartConfirmation || + ready == null || + ready.schedule.cacheFingerprint != schedule.cacheFingerprint || + !target.isAfter(now)) { return; } - final duration = target.difference(now); - _scheduleStartTimer = Timer(duration, () { - // Only add event if bloc is still active and schedule ID matches - if (!isClosed && _currentScheduleId == schedule.id) { - add(const ScheduleStarted()); - } + final intent = _BoundaryStartIntent( + ready.authority.key, + schedule.id, + schedule.cacheFingerprint, + target, + now, + _monotonicNow, + ); + _boundaryStartIntent = intent; + _scheduleStartTimer = Timer(target.difference(now), () { + if (_isBoundaryIntentCurrent(intent)) add(ScheduleStarted._owned(intent)); }); } + bool _isBoundaryIntentCurrent(_BoundaryStartIntent intent) { + final now = _nowProvider().toUtc(); + final lateness = now.difference(intent.target); + final drift = + (now.difference(intent.observedWall) - + (_monotonicNow - intent.observedMonotonic)) + .inMicroseconds + .abs(); + return !isClosed && + _queryForeground && + identical(_boundaryStartIntent, intent) && + LocalDataOperationGate.shared.isAvailable && + intent.key.generation == LocalDataOperationGate.shared.generation && + state.freshNearest?.authority.key == intent.key && + _currentScheduleId == intent.scheduleId && + state.schedule?.cacheFingerprint == intent.fingerprint && + lateness >= Duration.zero && + lateness <= stepNotificationFreshness && + drift <= stepClockTolerance.inMicroseconds; + } + void _startPreparationTimer() { if (isClosed || state.schedule == null) return; _preparationTimer?.cancel(); @@ -843,16 +1460,30 @@ class ScheduleBloc extends Bloc { _preparationTimer = null; } + @disposeMethod + Future disposeFromScope() => + StartupDependencyScope.release(this, close); + + Future? _closeFlight; @override Future close() { - // ✅ Proper cleanup: Cancel subscription and timer before closing - _upcomingScheduleSubscription?.cancel(); + final running = _closeFlight; + if (running != null) return running; + LocalDataOperationGate.shared.removeListener(_onDataGateChanged); + _subscriptionRevision++; + _queryMidnightTimer?.cancel(); + _queryDeviceClockTimer?.cancel(); + _retiredWatches.retire(_upcomingScheduleSubscription); + _upcomingScheduleSubscription = null; _scheduleStartTimer?.cancel(); _stopPreparationTimer(); _stepRevision++; _attemptedSteps.clear(); _stepRun = null; - return super.close(); + return _closeFlight = Future.wait([ + _retiredWatches.close(), + super.close(), + ]).then((_) {}).whenComplete(() => _closeFlight = null); } bool _snapshotInvalidated = false; @@ -994,21 +1625,6 @@ class ScheduleBloc extends Bloc { _activePreparationActionEvents = const []; } - bool _isAtPreparationStartBoundary( - ScheduleWithPreparationEntity schedule, - DateTime now, - ) { - return schedule.preparationStartTime.isAtSameMomentAs(now); - } - - bool _isPreparationOnGoing( - ScheduleWithPreparationEntity schedule, - DateTime now, - ) { - final start = schedule.preparationStartTime; - return start.isBefore(now) && schedule.scheduleTime.isAfter(now); - } - bool _isEnded(ScheduleDoneStatus doneStatus) { return doneStatus == ScheduleDoneStatus.normalEnd || doneStatus == ScheduleDoneStatus.lateEnd || diff --git a/lib/presentation/app/bloc/schedule/schedule_event.dart b/lib/presentation/app/bloc/schedule/schedule_event.dart index 0240f284..a5072625 100644 --- a/lib/presentation/app/bloc/schedule/schedule_event.dart +++ b/lib/presentation/app/bloc/schedule/schedule_event.dart @@ -16,13 +16,15 @@ final class ScheduleSubscriptionRequested extends ScheduleEvent { final class ScheduleUpcomingReceived extends ScheduleEvent { final ScheduleWithPreparationEntity? upcomingSchedule; + final int? generation; const ScheduleUpcomingReceived( - ScheduleWithPreparationEntity? upcomingScheduleWithPreparation, - ) : upcomingSchedule = upcomingScheduleWithPreparation; + ScheduleWithPreparationEntity? upcomingScheduleWithPreparation, { + this.generation, + }) : upcomingSchedule = upcomingScheduleWithPreparation; @override - List get props => [upcomingSchedule]; + List get props => [upcomingSchedule, generation]; } final class ScheduleAlarmPromptRequested extends ScheduleEvent { @@ -45,7 +47,9 @@ final class ScheduleAlarmPromptRequested extends ScheduleEvent { } final class ScheduleStarted extends ScheduleEvent { - const ScheduleStarted(); + const ScheduleStarted() : _boundaryIntent = null; + const ScheduleStarted._owned(this._boundaryIntent); + final _BoundaryStartIntent? _boundaryIntent; @override List get props => []; @@ -107,3 +111,82 @@ final class _NotificationPromptPresented extends ScheduleEvent { final class SchedulePreparationRecoveryRequested extends ScheduleEvent { const SchedulePreparationRecoveryRequested(); } + +// Internal subscription authority does not alter the public event contract. +final class _OwnedScheduleUpcomingReceived extends ScheduleUpcomingReceived { + const _OwnedScheduleUpcomingReceived( + super.upcomingScheduleWithPreparation, + int generation, + this.subscriptionRevision, + ) : super(generation: generation); + final int subscriptionRevision; + + @override + List get props => [...super.props, subscriptionRevision]; +} + +final class _OwnedScheduleUpcomingReadFailed extends ScheduleEvent { + const _OwnedScheduleUpcomingReadFailed( + this.generation, + this.subscriptionRevision, + ); + final int generation; + final int subscriptionRevision; + @override + List get props => [generation, subscriptionRevision]; +} + +final class ScheduleNearestQueryRetryRequested extends ScheduleEvent { + const ScheduleNearestQueryRetryRequested(this.queryKey); + final NearestQueryKey queryKey; + @override + List get props => [queryKey]; +} + +final class ScheduleNearestQueryContinueRequested extends ScheduleEvent { + const ScheduleNearestQueryContinueRequested(this.queryKey); + final NearestQueryKey queryKey; + @override + List get props => [queryKey]; +} + +final class ScheduleNearestQueryCancelRequested extends ScheduleEvent { + const ScheduleNearestQueryCancelRequested(this.queryKey); + final NearestQueryKey queryKey; + @override + List get props => [queryKey]; +} + +final class _NearestQueryReceived extends ScheduleEvent { + const _NearestQueryReceived(this.query, this.subscriptionRevision); + final NearestScheduleQuery query; + final int subscriptionRevision; + @override + List get props => [query, subscriptionRevision]; +} + +final class _NearestProjectionInvalidated extends ScheduleEvent { + const _NearestProjectionInvalidated({this.replaced = false}); + final bool replaced; +} + +final class _NotificationPreparationOwnershipChanged extends ScheduleEvent { + const _NotificationPreparationOwnershipChanged(); +} + +class _BoundaryStartIntent { + const _BoundaryStartIntent( + this.key, + this.scheduleId, + this.fingerprint, + this.target, + this.observedWall, + this.observedMonotonic, + ); + final NearestQueryKey key; + final String scheduleId; + final String fingerprint; + final DateTime target; + final DateTime observedWall; + final Duration observedMonotonic; +} diff --git a/lib/presentation/app/bloc/schedule/schedule_state.dart b/lib/presentation/app/bloc/schedule/schedule_state.dart index 5d24eb4c..bf30783c 100644 --- a/lib/presentation/app/bloc/schedule/schedule_state.dart +++ b/lib/presentation/app/bloc/schedule/schedule_state.dart @@ -10,6 +10,9 @@ class ScheduleState extends Equatable { this.notificationPromptOwner, this.hasPendingStartRecovery = false, this.isRecoveringStart = false, + this.nearestQuery = const NearestQueryIdle(), + this.hasNotificationPreparationOwner = false, + this.isResumedPreparation = false, }); const ScheduleState.initial() : this._(status: ScheduleStatus.initial); @@ -31,10 +34,12 @@ class ScheduleState extends Equatable { const ScheduleState.started( ScheduleWithPreparationEntity schedule, { bool isEarlyStarted = false, + bool isResumedPreparation = false, }) : this._( status: ScheduleStatus.started, schedule: schedule, isEarlyStarted: isEarlyStarted, + isResumedPreparation: isResumedPreparation, ); final ScheduleStatus status; @@ -44,12 +49,36 @@ class ScheduleState extends Equatable { final bool hasPendingStartRecovery; final bool isRecoveringStart; + /// The last projection is retained; it is not fresh start authority. + final NearestScheduleQuery nearestQuery; + final bool hasNotificationPreparationOwner; + bool get hasUpcomingReadFailure => nearestQuery is NearestQueryError; + NearestVerifiedSchedule? get freshNearest => switch (nearestQuery) { + NearestQueryReady(:final value) => value, + _ => null, + }; + NearestVerifiedSchedule? get staleNearest => nearestQuery.stale; + bool get hasOwnedPreparationSurface => + notificationPromptOwner != null || + hasNotificationPreparationOwner || + (schedule != null && + schedule!.doneStatus == ScheduleDoneStatus.notEnded && + schedule!.isStarted && + schedule!.startedAt != null && + schedule!.preparationFrozen); + + /// A read restored an existing run; it is not a new-start navigation event. + final bool isResumedPreparation; + ScheduleState copyWith({ ScheduleStatus? status, ScheduleWithPreparationEntity? schedule, bool? isEarlyStarted, bool? hasPendingStartRecovery, bool? isRecoveringStart, + NearestScheduleQuery? nearestQuery, + bool? hasNotificationPreparationOwner, + bool? isResumedPreparation, }) { return ScheduleState._( status: status ?? this.status, @@ -59,6 +88,11 @@ class ScheduleState extends Equatable { hasPendingStartRecovery: hasPendingStartRecovery ?? this.hasPendingStartRecovery, isRecoveringStart: isRecoveringStart ?? this.isRecoveringStart, + nearestQuery: nearestQuery ?? this.nearestQuery, + hasNotificationPreparationOwner: + hasNotificationPreparationOwner ?? + this.hasNotificationPreparationOwner, + isResumedPreparation: isResumedPreparation ?? this.isResumedPreparation, ); } @@ -82,5 +116,8 @@ class ScheduleState extends Equatable { notificationPromptOwner, hasPendingStartRecovery, isRecoveringStart, + nearestQuery, + hasNotificationPreparationOwner, + isResumedPreparation, ]; } diff --git a/lib/presentation/app/screens/app.dart b/lib/presentation/app/screens/app.dart index c21f6d9c..486cfe06 100644 --- a/lib/presentation/app/screens/app.dart +++ b/lib/presentation/app/screens/app.dart @@ -17,6 +17,7 @@ import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; import 'package:on_time_front/presentation/shared/router/go_router.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; class App extends StatelessWidget { const App({super.key}); @@ -37,7 +38,7 @@ class App extends StatelessWidget { ), BlocProvider(create: (context) => AlarmGateCubit()), ], - child: const AppView(), + child: const DeviceTimeZoneScope(child: AppView()), ); } } @@ -60,6 +61,13 @@ class _AppRouterView extends StatefulWidget { class _AppRouterViewState extends State<_AppRouterView> with WidgetsBindingObserver { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final zone = DeviceTimeZoneScope.stateOf(context).identifier; + if (zone != null) context.read().observeDeviceTimeZone(zone); + } + static const _logTag = '[AppAlarmLaunch]'; late final _router = goRouterConfig( diff --git a/lib/presentation/backup/backup_processing_panel.dart b/lib/presentation/backup/backup_processing_panel.dart new file mode 100644 index 00000000..77c9861d --- /dev/null +++ b/lib/presentation/backup/backup_processing_panel.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; + +String? backupProcessingError(Object error, bool ko) { + if (error is BackupProcessingCleanupFailure) { + final original = error.originalError; + final reason = original == null + ? '' + : '${backupProcessingError(original, ko) ?? ''}\n'; + return '$reason${ko ? '임시 자료 정리가 아직 확인되지 않았어요. 같은 작업의 정리를 다시 시도하세요.' : 'Temporary cleanup is not confirmed. Retry cleanup for this operation.'}'; + } + if (error is! BackupProcessingFailure) return null; + return switch (error.kind) { + BackupFailureKind.userCancelled => + ko ? '백업 작업을 취소했어요.' : 'The backup operation was cancelled.', + BackupFailureKind.resourceLimit => + ko + ? '이 백업은 지원하는 크기 또는 처리 한도를 넘었어요. 원본 파일과 현재 데이터는 유지됩니다.' + : 'This backup exceeds a supported size or processing limit. The source file and current data are preserved.', + BackupFailureKind.unsupportedRepresentation => + ko + ? '이 백업 버전 또는 값의 표현은 지원하지 않아요. 호환되는 앱에서 백업을 확인해 주세요.' + : 'This backup version or value representation is not supported. Check it with a compatible app.', + BackupFailureKind.authentication => + ko + ? '비밀번호가 맞지 않거나 파일의 무결성을 확인할 수 없어요. 비밀번호와 원본 파일을 확인해 주세요.' + : 'The password or file integrity could not be verified. Check the password and original file.', + BackupFailureKind.dataInvariant => + ko + ? '백업 내용의 값이나 연결 관계를 확인할 수 없어요. 원본 파일을 다시 확인해 주세요.' + : 'The backup contains invalid values or relationships. Check the original file.', + BackupFailureKind.timeZoneChoiceRequired => + ko + ? '일정의 시간대 또는 반복 순서를 명시적으로 검토해야 해요. 현재 데이터는 변경되지 않았으며 이 화면에서는 해당 내용을 수정할 수 없어요.' + : 'The time-zone choice or recurrence order needs explicit review. Current data is unchanged. This screen cannot edit those choices yet.', + BackupFailureKind.inputOutput => + ko + ? '파일을 끝까지 읽거나 저장하지 못했어요. 파일 접근 권한과 저장 공간을 확인한 뒤 다시 시도하세요.' + : 'The file could not be fully read or saved. Check file access and available storage, then retry.', + }; +} + +/// A process owner survives this widget; disposing the screen neither releases +/// a lease nor treats an outstanding provider/crypto/database call as complete. +class BackupProcessingPanel extends StatefulWidget { + const BackupProcessingPanel({super.key, this.owner}); + final BackupProcessingOwner? owner; + @override + State createState() => _BackupProcessingPanelState(); +} + +class _BackupProcessingPanelState extends State { + bool retrying = false; + String? failure; + BackupProcessingOwner get owner => + widget.owner ?? BackupProcessingOwner.shared; + @override + Widget build(BuildContext context) { + final ko = Localizations.localeOf(context).languageCode == 'ko'; + return StreamBuilder( + stream: owner.changes, + builder: (context, snapshot) { + final lease = owner.active; + if (lease == null || lease.phase == BackupProcessingPhase.preview) { + return const SizedBox.shrink(); + } + final phase = lease.phase; + final text = switch (phase) { + BackupProcessingPhase.reading => + ko ? '백업 파일을 읽고 있어요.' : 'Reading the backup file.', + BackupProcessingPhase.validating => + ko ? '백업 내용을 검증하고 있어요.' : 'Validating the backup.', + BackupProcessingPhase.encrypting => + ko ? '백업을 암호화하고 있어요.' : 'Encrypting the backup.', + BackupProcessingPhase.choosingDestination => + ko + ? '파일 저장이 끝날 때까지 기다려 주세요.' + : 'Waiting for the file save to finish.', + BackupProcessingPhase.applying => + ko ? '확인한 백업을 적용하고 있어요.' : 'Applying the confirmed backup.', + BackupProcessingPhase.cancelling => + ko + ? '취소 중이에요. 파일 읽기·저장과 임시 자료 정리가 끝날 때까지 기다려 주세요. 시스템 파일 창이 열려 있다면 그 창에서 저장 또는 취소를 마쳐 주세요.' + : 'Cancelling. Waiting for file reading, saving and temporary cleanup to finish. If a system file window is open, finish saving or cancel in that window.', + BackupProcessingPhase.cleanupPending => + ko ? '임시 자료 정리가 남아 있어요.' : 'Temporary cleanup remains.', + BackupProcessingPhase.preview => '', + }; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics(liveRegion: true, child: Text(text)), + if (failure != null) Text(failure!), + if (phase == BackupProcessingPhase.cleanupPending) + TextButton( + onPressed: retrying + ? null + : () async { + setState(() { + retrying = true; + failure = null; + }); + try { + await owner.retryCleanup(); + } catch (e) { + if (mounted) { + setState( + () => failure = + backupProcessingError(e, ko) ?? + (ko + ? '정리를 다시 시도해 주세요.' + : 'Please retry cleanup.'), + ); + } + } finally { + if (mounted) setState(() => retrying = false); + } + }, + child: Text( + ko ? '같은 작업 정리 다시 시도' : 'Retry this operation’s cleanup', + ), + ) + else if (lease.cancellationAllowed && + phase != BackupProcessingPhase.cancelling) + TextButton( + onPressed: owner.requestCancellation, + child: Text(ko ? '백업 작업 취소' : 'Cancel backup operation'), + ), + ], + ), + ), + ); + }, + ); + } +} + +class BackupPreviewNotices extends StatelessWidget { + const BackupPreviewNotices({super.key, required this.input}); + final BackupRestoreInput input; + @override + Widget build(BuildContext context) { + final ko = Localizations.localeOf(context).languageCode == 'ko'; + final preview = input.preview; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (preview.cutoffLiteral != null) + Text( + ko + ? '백업 시점의 시간대가 기록되지 않았어요. 표시한 원래 날짜와 시각은 절대시각이 아니며 기기 시간대로 변환하지 않습니다.' + : 'The backup cutoff has no recorded time zone. Its original date and time are shown without converting them into an instant or this device’s zone.', + ), + if (preview.uncertainHistoryCount > 0) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + ko + ? '시간대 규칙의 근거를 확정할 수 없는 기록 ${preview.uncertainHistoryCount}개를 원래 값 그대로 보존합니다. 보존만으로 새 준비 시작이나 알림이 허용되지는 않습니다.' + : '${preview.uncertainHistoryCount} records retain their original values with uncertain time-zone provenance. Preservation does not authorize a new preparation start or alarm.', + ), + ), + for (final example in preview.uncertainHistoryExamples) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '${example.name ?? (ko ? '반복 제외 기록' : 'Excluded recurrence')} · ${example.civil.toIso8601String().replaceAll('Z', '')} · ${example.zone}\n${_uncertaintyReason(example.reason, ko)}', + ), + ), + if (preview.timezoneChangeCount > 0) ...[ + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + ko + ? '현재 시간대 규칙에 따라 미래 일정 ${preview.timezoneChangeCount}개의 절대시각이 다시 계산됩니다. 교체를 누르면 이 변경도 적용됩니다.' + : '${preview.timezoneChangeCount} future schedule instants will be recalculated with current time-zone rules. Confirming replacement also applies these changes.', + ), + ), + TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => _ImpactDialog(input: input), + ), + child: Text(ko ? '변경되는 일정 모두 확인' : 'Review all changed schedules'), + ), + ], + ], + ); + } + + String _uncertaintyReason( + BackupHistoryUncertaintyReason reason, + bool ko, + ) => switch (reason) { + BackupHistoryUncertaintyReason.historicalProvenance => + ko + ? '당시 절대시각과 반복 순서의 근거가 불확실합니다.' + : 'The original instant and recurrence order are uncertain.', + BackupHistoryUncertaintyReason.unavailableHistoricalZone => + ko + ? '역사 시간대를 확인할 수 없습니다. 원래 값을 보존합니다.' + : 'The historical time zone is unavailable. Original values are preserved.', + BackupHistoryUncertaintyReason.protectedStartZone => + ko + ? '보호된 준비 시작 사실입니다. 실행 재개는 별도 확인이 필요합니다.' + : 'Protected preparation start facts are preserved. Resuming requires a separate check.', + BackupHistoryUncertaintyReason.historicalOrdinal => + ko + ? '역사 반복 순서의 시간대 규칙 근거가 불확실합니다.' + : 'The time-zone rules behind the historical recurrence order are uncertain.', + }; +} + +class _ImpactDialog extends StatefulWidget { + const _ImpactDialog({required this.input}); + final BackupRestoreInput input; + @override + State<_ImpactDialog> createState() => _ImpactDialogState(); +} + +class _ImpactDialogState extends State<_ImpactDialog> { + late Future page; + int start = 1; + @override + void initState() { + super.initState(); + page = widget.input.timeZoneImpacts(); + } + + @override + Widget build(BuildContext context) { + final ko = Localizations.localeOf(context).languageCode == 'ko'; + return AlertDialog( + scrollable: true, + title: Text(ko ? '시간대 변경 영향' : 'Time-zone changes'), + content: FutureBuilder( + future: page, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text( + ko + ? '목록을 읽지 못했어요. 닫고 다시 시도하세요.' + : 'The list could not be read. Close and retry.', + ); + } + if (!snapshot.hasData) return const CircularProgressIndicator(); + final data = snapshot.data!; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$start–${start + data.items.length - 1} / ${widget.input.preview.timezoneChangeCount}', + ), + for (final item in data.items) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + '${item.name}\n${item.civil.toIso8601String().replaceAll('Z', '')} · ${item.zone}\n${item.previousOffset == null ? (ko ? '미선택' : 'Unspecified') : _offset(item.previousOffset!)} → ${_offset(item.newOffset)}', + ), + ), + if (data.nextCursor != null) + TextButton( + onPressed: () => setState(() { + start += data.items.length; + page = widget.input.timeZoneImpacts( + cursor: data.nextCursor, + ); + }), + child: Text(ko ? '다음 일정' : 'Next schedules'), + ), + ], + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(ko ? '닫기' : 'Close'), + ), + ], + ); + } +} + +String _offset(int seconds) { + final value = seconds.abs(); + final hours = (value ~/ 3600).toString().padLeft(2, '0'); + final minutes = ((value % 3600) ~/ 60).toString().padLeft(2, '0'); + final remainder = value % 60; + return 'UTC${seconds < 0 ? '-' : '+'}$hours:$minutes${remainder == 0 ? '' : ':${remainder.toString().padLeft(2, '0')}'}'; +} diff --git a/lib/presentation/backup/backup_recurring_time_review_screen.dart b/lib/presentation/backup/backup_recurring_time_review_screen.dart new file mode 100644 index 00000000..987235f8 --- /dev/null +++ b/lib/presentation/backup/backup_recurring_time_review_screen.dart @@ -0,0 +1,687 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/backup_recurring_time_review.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_settings_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurring_time_correction_plan_summary.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; + +/// Borrows an authenticated candidate. This route never disposes it, creates a +/// ready input, or replaces app data. The parent revalidates after this returns. +class BackupRecurringTimeReviewScreen extends StatefulWidget { + const BackupRecurringTimeReviewScreen({ + super.key, + required this.input, + required this.issue, + }); + final BackupTimeReviewInput input; + final BackupTimeReviewIssue issue; + @override + State createState() => + _BackupRecurringTimeReviewScreenState(); +} + +class _BackupRecurringTimeReviewScreenState + extends State { + RecurrenceRule? _rule; + BackupRecurringTimePlan? _plan; + Set _excluded = {}, _detached = {}, _unmatched = {}, _possible = {}; + bool _busy = false, + _saving = false, + _confirming = false, + _explicitEnd = false; + bool _returning = false; + bool _needsWhole = false, _wholeRequested = false, _wholeConfirmed = false; + int _owner = 0, _draft = 0, _conflictPage = 0, _possiblePage = 0; + String? _error; + String text(String ko, String en) => recurrenceText(context, ko, en); + BackupRecurringTimeReviewPort? get _port => + widget.input is BackupRecurringTimeReviewPort + ? widget.input as BackupRecurringTimeReviewPort + : null; + bool owns(int owner, int draft) => + mounted && _owner == owner && _draft == draft; + bool get _enabled => !_busy && !_saving && !_confirming && !_returning; + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(BackupRecurringTimeReviewScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.input != widget.input || oldWidget.issue != widget.issue) { + _load(); + } + } + + @override + void dispose() { + _owner++; + super.dispose(); + } + + void _invalidate() { + _draft++; + _plan = null; + _detached = {}; + _unmatched = {}; + _possible = {}; + _wholeConfirmed = false; + _conflictPage = 0; + _possiblePage = 0; + _error = null; + } + + Future _load() async { + final owner = ++_owner; + setState(() { + _invalidate(); + _busy = true; + _saving = false; + _returning = false; + _confirming = false; + _rule = null; + _excluded = {}; + _explicitEnd = false; + _needsWhole = false; + _wholeRequested = false; + }); + final draft = _draft; + final input = widget.input; + final summary = input.summary; + try { + final port = _port; + if (port == null) throw const BackupTimeReviewStale(); + final rule = await port.recurrenceRule(widget.issue.id); + if (!owns(owner, draft)) return; + final current = input.summary; + if (summary.identity != current.identity || + summary.revision != current.revision || + summary.rulesIdentity != current.rulesIdentity) { + throw const BackupTimeReviewStale(); + } + setState(() { + _rule = rule; + _busy = false; + }); + } catch (_) { + if (owns(owner, draft)) { + setState(() { + _busy = false; + _error = text( + '반복 규칙을 읽지 못했어요. 상위 검토로 돌아가 전체 백업을 다시 확인해주세요.', + 'The recurrence rule could not be read. Return to the parent review and validate the entire backup again.', + ); + }); + } + } + } + + RecurrenceRule _changedRule({ + DateTime? start, + String? zone, + RepeatedCivilTime? repeated, + }) { + final r = _rule!; + return RecurrenceRule( + frequency: r.frequency, + start: start ?? r.start, + timeZoneId: zone ?? r.timeZoneId, + interval: r.interval, + weekdays: r.weekdays, + monthly: r.monthly, + monthDay: r.monthDay, + ordinal: r.ordinal, + monthWeekday: r.monthWeekday, + count: r.count, + until: r.until, + repeatedTime: repeated ?? r.repeatedTime, + ); + } + + Future _pickCivil(bool dateOnly) async { + final owner = _owner, draft = _draft; + final value = await showCivilDateTimePicker( + context: context, + initialCivil: _rule!.start, + dateOnly: dateOnly, + title: text('새 반복 시작', 'New recurrence start'), + ); + if (value == null || !owns(owner, draft)) return; + try { + final rule = _changedRule(start: value); + setState(() { + _invalidate(); + _rule = rule; + _excluded = {}; + }); + } catch (_) { + setState( + () => _error = text( + '시작과 종료 조건을 확인해주세요.', + 'Check the start and end conditions.', + ), + ); + } + } + + Future _pickZone() async { + final owner = _owner, draft = _draft; + final value = await showScheduleTimeZonePicker( + context: context, + currentZone: _rule!.timeZoneId, + civil: _rule!.start, + offsetSeconds: null, + isCurrent: () => owns(owner, draft), + ); + if (value == null || !owns(owner, draft)) return; + setState(() { + final rule = _changedRule(zone: value); + _invalidate(); + _rule = rule; + _excluded = {}; + }); + } + + Future _pickRule() async { + final owner = _owner, draft = _draft; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => RecurrenceSettingsSheet( + start: _rule!.start, + timeZoneId: _rule!.timeZoneId, + initial: _rule, + allowNone: false, + ), + ); + if (!owns(owner, draft) || result?.rule == null) return; + setState(() { + _invalidate(); + _rule = result!.rule; + _explicitEnd = true; + _excluded = {}; + }); + } + + Future _review({Set? excluded}) async { + if (!_enabled || _rule == null || _port == null) return; + setState(() { + _invalidate(); + _busy = true; + if (excluded != null) _excluded = Set.of(excluded); + }); + final owner = _owner, draft = _draft; + final summary = widget.input.summary; + try { + final plan = await _port!.reviewRecurrence( + BackupRecurringTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: widget.issue.id, + rule: _rule!, + endExplicitlyChosen: _explicitEnd, + replaceWholeSourceInterval: _wholeRequested, + excludedConflictSlots: _excluded, + ), + ); + if (!owns(owner, draft)) return; + setState(() { + _plan = plan; + _busy = false; + }); + } on BackupRecurringWholeReplacementRequired { + if (owns(owner, draft)) { + setState(() { + _busy = false; + _needsWhole = true; + _wholeRequested = false; + _error = text( + '기준 회차 앞에도 시간대가 미확정인 미래 구간이 남아 있어요. 원 생성 구간 전체 교체 여부를 직접 선택하고 다시 검토해주세요.', + 'Unresolved future generation remains before the anchor. Explicitly choose whether to replace the whole original interval, then review again.', + ); + }); + } + } on RecurringTimeCorrectionCountRequired { + if (owns(owner, draft)) { + setState(() { + _busy = false; + _error = text( + '반복 횟수나 종료 날짜를 직접 선택해야 해요. 반복·종료 설정을 열어주세요.', + 'Explicitly choose an occurrence count or end date in recurrence settings.', + ); + }); + } + } catch (_) { + if (owns(owner, draft)) { + setState(() { + _busy = false; + _error = text( + '전체 계획을 검증하지 못했어요. 규칙을 수정하거나 상위 검토로 돌아가 다시 확인해주세요.', + 'The entire plan could not be verified. Adjust the rule or return to the parent review.', + ); + }); + } + } + } + + bool get _canChoose { + final p = _plan; + if (!_enabled || + p == null || + p.conflicts.conflicts.isNotEmpty || + p.unresolvedOverrides.isNotEmpty || + (p.replacesWholeSourceInterval && !_wholeConfirmed)) { + return false; + } + const equality = SetEquality(); + return equality.equals( + _detached, + p.mapping.rows + .where((r) => r.detached) + .map((r) => r.original.id) + .toSet(), + ) && + equality.equals( + _unmatched, + p.mapping.exclusions + .where((r) => r.slot == null) + .map( + (r) => + RecurringTimeCorrectionPlanSummary.exclusionKey(r.original), + ) + .toSet(), + ) && + equality.equals( + _possible, + p.conflicts.possibleOverlaps.map((r) => r.id).toSet(), + ); + } + + Future _choose() async { + if (!_canChoose) return; + final owner = _owner, draft = _draft, plan = _plan!; + final port = _port!; + final choice = BackupRecurringTimeChoice( + plan: plan, + confirmedDetachedIds: _detached, + confirmedUnmatchedExclusions: _unmatched, + acknowledgedPossibleIds: _possible, + confirmedWholeSourceReplacement: _wholeConfirmed, + ); + setState(() => _confirming = true); + final yes = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + scrollable: true, + title: Text(text('반복 변경을 확인할까요?', 'Confirm recurrence changes?')), + content: Text( + text( + '검토한 회차 연결, 단발 보존, 제외 및 불확실 영향만 백업 후보에 반영합니다. 현재 앱 데이터는 바뀌지 않습니다. 상위 화면에서 백업 전체를 다시 검증한 후 별도로 복원을 확인해야 합니다.', + 'Apply only the reviewed associations, standalone records, exclusions and uncertain impacts to the backup candidate. Current app data is unchanged. The parent must validate the whole backup and separately confirm restoration.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(text('돌아가기', 'Back')), + ), + FilledButton( + key: const Key('confirm-backup-recurrence-choice'), + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(text('후보에 반영', 'Apply to candidate')), + ), + ], + ), + ); + if (!owns(owner, draft)) return; + setState(() => _confirming = false); + if (yes != true) return; + setState(() => _saving = true); + try { + await port.chooseRecurrence(choice); + if (!owns(owner, draft)) return; + setState(() { + _saving = false; + _returning = true; + }); + // PopScope must rebuild before the programmatic return. Retire this + // callback if the widget's candidate or issue changes in that frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (owns(owner, draft)) Navigator.of(context).pop(); + }); + } catch (_) { + if (owns(owner, draft)) { + setState(() { + _saving = false; + _invalidate(); + _error = text( + '이 계획을 확인할 수 없어요. 상위 검토로 돌아가 백업 전체를 다시 검증해주세요.', + 'This plan could not be confirmed. Return to the parent review and validate the entire backup again.', + ); + }); + } + } + } + + String civil(DateTime value) => + CivilDateTime.fromFields(value).toCivilIso8601String(); + @override + Widget build(BuildContext context) { + final rule = _rule, p = _plan; + final conflicts = p?.conflicts.conflicts ?? []; + final possible = p?.conflicts.possibleOverlaps ?? []; + return PopScope( + canPop: !_saving, + child: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(text('백업 반복 시간 검토', 'Review backup recurrence')), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + text( + '반복 일정의 새 시각과 변경 영향을 확인해주세요. 이 단계에서는 현재 앱 데이터를 바꾸지 않습니다.', + 'Review the new recurrence times and their effects. Current app data is unchanged at this step.', + ), + ), + if (_busy || _saving) const LinearProgressIndicator(), + if (_error != null) + Text( + _error!, + key: const Key('backup-recurrence-error'), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (rule != null) ...[ + Text( + '${civil(rule.start)} · ${rule.timeZoneId}\n${recurrenceLabel(context, rule)} · ${recurrenceEndLabel(context, rule)}', + ), + Wrap( + spacing: 8, + children: [ + TextButton( + key: const Key('backup-recurrence-date'), + onPressed: _enabled ? () => _pickCivil(true) : null, + child: Text(text('시작 날짜', 'Start date')), + ), + TextButton( + key: const Key('backup-recurrence-clock'), + onPressed: _enabled ? () => _pickCivil(false) : null, + child: Text(text('시작 시각', 'Start time')), + ), + ], + ), + TextButton( + key: const Key('backup-recurrence-zone'), + onPressed: _enabled ? _pickZone : null, + child: Text(text('시간대 선택', 'Choose time zone')), + ), + TextButton( + key: const Key('backup-recurrence-rule'), + onPressed: _enabled ? _pickRule : null, + child: Text( + text('반복·종료 직접 선택', 'Choose recurrence and end condition'), + ), + ), + Text( + text( + '두 번 나타나는 시각의 발생 순서', + 'Occurrence when a local time repeats', + ), + ), + Wrap( + spacing: 8, + children: [ + for (final value in RepeatedCivilTime.values) + ChoiceChip( + key: ValueKey('backup-recurrence-repeat-${value.name}'), + label: Text( + value == RepeatedCivilTime.first + ? text('첫 번째', 'First') + : text('두 번째', 'Second'), + ), + selected: rule.repeatedTime == value, + onSelected: _enabled + ? (_) { + setState(() { + final next = _changedRule(repeated: value); + _invalidate(); + _rule = next; + _excluded = {}; + }); + } + : null, + ), + ], + ), + if (_needsWhole) + CheckboxListTile( + key: const Key('request-backup-recurrence-whole'), + contentPadding: EdgeInsets.zero, + value: _wholeRequested, + onChanged: _enabled + ? (value) => setState(() { + _invalidate(); + _excluded = {}; + _wholeRequested = value == true; + }) + : null, + title: Text( + text( + '해당 원 생성 구간 전체를 교체하는 계획을 검토할게요. 기준 회차 앞의 미생성 구간도 포함됩니다.', + 'Review replacing this entire original interval, including unmaterialized occurrences before the anchor.', + ), + ), + ), + FilledButton( + key: const Key('review-backup-recurrence'), + onPressed: _enabled ? () => _review() : null, + child: Text(text('전체 계획 검토', 'Review complete plan')), + ), + ], + if (p != null) ...[ + const Divider(), + Text( + '${text('원래 규칙', 'Original rule')}: ${recurrenceLabel(context, p.originalRule)} · ${p.originalRule.timeZoneId} · ${recurrenceEndLabel(context, p.originalRule)}', + ), + Text( + '${text('원 생성 구간', 'Original interval')}: ${civil(p.originalFrom)} → ${p.originalBefore == null ? text('끝 없음', 'No end') : civil(p.originalBefore!)}\n${text('폐쇄 경계', 'Close at')}: ${civil(p.closeAt)}', + ), + Text( + p.anchor.scheduleId == null + ? text( + '기존 개별 일정 0개: 앞으로 생성할 반복 일정의 시작과 종료 조건을 확인해주세요.', + '0 existing appointments: review the start and end conditions of future recurring appointments.', + ) + : '${text('기준 회차', 'Anchor occurrence')}: ${civil(p.anchor.originalSlot)} · #${p.anchor.originalOrdinal}', + ), + if (p.mapping.firstSlot != null) + Text( + '${text('새 규칙의 첫 회차(제외 전)', 'First rule occurrence (before exclusions)')}: ${civil(p.mapping.firstSlot!.civilTime)} · ${p.mapping.rule.timeZoneId}\n${p.mapping.firstSlot!.instantUtc.toIso8601String()}', + ), + if (p.conflicts.earliestPreparationUtc != null) + Text( + '${text('가장 이른 준비 시작', 'Earliest preparation start')}: ${p.conflicts.earliestPreparationUtc!.toIso8601String()}', + ), + if (p.replacesWholeSourceInterval) + CheckboxListTile( + key: const Key('confirm-backup-recurrence-whole'), + contentPadding: EdgeInsets.zero, + value: _wholeConfirmed, + onChanged: _enabled + ? (value) => + setState(() => _wholeConfirmed = value == true) + : null, + title: Text( + text( + '표시된 원 구간 전체에서 기존 규칙의 생성이 끝나며, 기준 회차 앞 미생성 일정도 새 규칙으로 바뀌는 영향을 확인했어요.', + 'I understand that generation by the old rule ends throughout the shown interval, including unmaterialized appointments before the anchor.', + ), + ), + ), + RecurringTimeCorrectionPlanSummary( + mapping: p.mapping, + unresolvedOverrides: p.unresolvedOverrides, + confirmedDetachedIds: _detached, + confirmedUnmatchedExclusions: _unmatched, + enabled: _enabled, + onDetachedChanged: (value) => + setState(() => _detached = value), + onUnmatchedExclusionsChanged: (value) => + setState(() => _unmatched = value), + onResolveOverride: (_) { + if (_enabled) Navigator.of(context).pop(); + }, + ), + if (p.unresolvedOverrides.isNotEmpty) + Text( + text( + '상위 시간 검토로 돌아가 표시된 일정의 시각을 먼저 선택한 뒤 반복 규칙을 다시 검토해주세요.', + 'Return to the parent time review, choose the times of the listed appointments, then review this rule again.', + ), + ), + if (conflicts.isNotEmpty) ...[ + Text( + text('확인된 겹침을 해결해주세요.', 'Resolve the confirmed overlaps.'), + ), + for (final conflict + in conflicts.skip(_conflictPage * 10).take(10)) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${civil(conflict.slot.civilTime)} · ${p.mapping.rule.timeZoneId}\n${conflict.other.schedule.scheduleName}', + ), + if (conflict.persistent) + Text( + text( + '계속 반복되는 겹침입니다. 규칙을 변경해주세요.', + 'This overlap repeats indefinitely. Change the rule.', + ), + ) + else + TextButton( + key: ValueKey( + 'exclude-backup-slot-${conflict.slot.key}', + ), + onPressed: _enabled + ? () => _review( + excluded: { + ..._excluded, + conflict.slot.key, + }, + ) + : null, + child: Text( + text( + '새 회차 제외·기존 일정은 단발 보존', + 'Exclude new slot; preserve existing appointment as standalone', + ), + ), + ), + ], + ), + ), + ), + _pages( + 'conflict', + conflicts.length, + _conflictPage, + (value) => setState(() => _conflictPage = value), + ), + ], + if (possible.isNotEmpty) ...[ + Text( + text( + '겹침이 불확실한 일정 ${possible.length}개', + '${possible.length} appointments with uncertain overlap', + ), + ), + for (final item in possible.skip(_possiblePage * 10).take(10)) + Text( + '${item.name}\n${item.originalCivil == null ? '' : civil(item.originalCivil!)} · ${item.timeZoneId ?? ''}', + ), + _pages( + 'possible', + possible.length, + _possiblePage, + (value) => setState(() => _possiblePage = value), + ), + CheckboxListTile( + key: const Key('ack-backup-recurrence-possible'), + contentPadding: EdgeInsets.zero, + value: _possible.isNotEmpty, + onChanged: _enabled + ? (value) => setState( + () => _possible = value == true + ? possible.map((p) => p.id).toSet() + : {}, + ) + : null, + title: Text( + text( + '표시된 미확정 영향 전체를 확인했어요. 남은 문제를 해결하기 전에는 복원할 수 없어요.', + 'I acknowledge all listed uncertain impacts. Remaining issues must be resolved before restoration.', + ), + ), + ), + ], + FilledButton( + key: const Key('choose-backup-recurrence'), + onPressed: _canChoose ? _choose : null, + child: Text(text('변경 내용 확인', 'Confirm changes')), + ), + ], + TextButton( + key: const Key('cancel-backup-recurrence'), + onPressed: _saving || _confirming || _returning + ? null + : () => Navigator.of(context).pop(), + child: Text(text('상위 검토로 돌아가기', 'Return to parent review')), + ), + ], + ), + ), + ), + ); + } + + Widget _pages(String kind, int count, int page, ValueChanged changed) => + count <= 10 + ? const SizedBox.shrink() + : Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TextButton( + key: ValueKey('backup-recurrence-$kind-previous'), + onPressed: _enabled && page > 0 ? () => changed(page - 1) : null, + child: Text(text('이전', 'Previous')), + ), + Text( + '${page * 10 + 1}–${(page * 10 + 10).clamp(0, count)} / $count', + ), + TextButton( + key: ValueKey('backup-recurrence-$kind-next'), + onPressed: _enabled && page * 10 + 10 < count + ? () => changed(page + 1) + : null, + child: Text(text('다음', 'Next')), + ), + ], + ); +} diff --git a/lib/presentation/backup/backup_time_review_screen.dart b/lib/presentation/backup/backup_time_review_screen.dart new file mode 100644 index 00000000..019fb1bd --- /dev/null +++ b/lib/presentation/backup/backup_time_review_screen.dart @@ -0,0 +1,709 @@ +import 'dart:async'; +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; + +/// Returns a newly verified input, never applies it. The parent owns the final +/// replacement confirmation and also awaits idempotent selection cleanup. +class BackupTimeReviewScreen extends StatefulWidget { + const BackupTimeReviewScreen({ + super.key, + required this.input, + this.onReviewRecurrence, + }); + final BackupTimeReviewInput input; + final Future Function(BackupTimeReviewInput, BackupTimeReviewIssue)? + onReviewRecurrence; + @override + State createState() => _BackupTimeReviewScreenState(); +} + +class _BackupTimeReviewScreenState extends State { + late BackupTimeReviewInput _input; + final _retired = []; + BackupTimeReviewIssuePage? _page; + BackupTimeReviewIssue? _selected; + BackupTimeFieldReview? _field; + CivilDateTime? _civil; + String _zone = ''; + int? _offset; + bool _possibleAcknowledged = false; + String? _cursor, _error; + final _previous = []; + int _owner = 0, _draft = 0; + bool _busy = false, + _closing = false, + _cleanupFailed = false, + _allowPop = false; + String text(String ko, String en) => + Localizations.localeOf(context).languageCode == 'ko' ? ko : en; + bool owns(int owner) => mounted && owner == _owner && !_closing; + + @override + void initState() { + super.initState(); + _input = widget.input; + _load(); + } + + @override + void didUpdateWidget(BackupTimeReviewScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.input != widget.input) { + _owner++; + _retired.add(_input); + _input = widget.input; + _previous.clear(); + _cursor = null; + _closing = false; + _allowPop = false; + _cleanupFailed = false; + _load(); + } + } + + @override + void dispose() { + _owner++; + // The service retains failed cleanup in its lease. The parent awaits the + // same cleanup, including forced route removal; no unhandled Future escapes. + for (final selected in [..._retired, _input]) { + unawaited(selected.dispose().catchError((Object _) {})); + } + super.dispose(); + } + + Future _load() async { + final owner = ++_owner; + final input = _input; + setState(() { + _busy = true; + _error = null; + _page = null; + _clearDraft(); + }); + try { + for (final old in List.of(_retired)) { + await old.dispose(); + _retired.remove(old); + } + final page = await input.issues(cursor: _cursor); + if (!owns(owner)) return; + setState(() { + _page = page; + _busy = false; + }); + } catch (_) { + if (!owns(owner)) return; + setState(() { + _busy = false; + _cleanupFailed = _retired.isNotEmpty; + _error = text( + '검토 내용을 읽지 못했어요. 전체 검증을 다시 하거나 취소해주세요.', + 'Could not load this review. Validate again or cancel.', + ); + }); + } + } + + void _clearDraft() { + _selected = null; + _civil = null; + _zone = ''; + _field = null; + _offset = null; + _possibleAcknowledged = false; + _draft++; + } + + void _edit(BackupTimeReviewIssue issue) { + setState(() { + _clearDraft(); + _selected = issue; + _civil = issue.civil; + _zone = issue.zone ?? ''; + _error = null; + }); + } + + void _changed() { + _draft++; + _field = null; + _offset = null; + _possibleAcknowledged = false; + _error = null; + } + + Future _pickCivil(bool dateOnly) async { + final owner = _owner, draft = _draft; + final value = await showCivilDateTimePicker( + context: context, + initialCivil: _civil!.toUtcCarrier(), + dateOnly: dateOnly, + title: text(dateOnly ? '날짜' : '시간', dateOnly ? 'Date' : 'Time'), + ); + if (!owns(owner) || draft != _draft || value == null) return; + setState(() { + _civil = CivilDateTime.fromFields(value); + _changed(); + }); + } + + Future _pickZone() async { + final owner = _owner, draft = _draft; + final value = await showScheduleTimeZonePicker( + context: context, + currentZone: _zone, + civil: _civil?.toUtcCarrier(), + offsetSeconds: _offset, + isCurrent: () => owns(owner) && draft == _draft, + ); + if (!owns(owner) || draft != _draft || value == null) return; + setState(() { + _zone = value; + _changed(); + }); + } + + Future _reviewField() async { + final owner = _owner, draft = _draft; + final input = _input; + final summary = input.summary; + setState(() { + _busy = true; + _error = null; + _field = null; + _offset = null; + _possibleAcknowledged = false; + }); + try { + final field = await input.review( + BackupTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: _selected!.id, + civil: _civil!, + zone: _zone, + ), + ); + if (!owns(owner) || draft != _draft) return; + setState(() { + _field = field; + _busy = false; + }); + } catch (_) { + if (!owns(owner)) return; + setState(() { + _busy = false; + _error = text( + '이 시각을 확인하지 못했어요. 시간대를 선택하거나 전체 검증을 다시 해주세요.', + 'This time could not be reviewed. Choose a time zone or validate again.', + ); + }); + } + } + + String _offsetLabel(int offset) { + final value = offset.abs(); + final hours = (value ~/ 3600).toString().padLeft(2, '0'); + final minutes = (value % 3600 ~/ 60).toString().padLeft(2, '0'); + final seconds = value % 60; + return 'UTC${offset < 0 ? '-' : '+'}$hours:$minutes${seconds == 0 ? '' : ':${seconds.toString().padLeft(2, '0')}'}'; + } + + TimeCorrectionConflictProof? get _proof => _field?.conflictsByOffset[_offset]; + bool get _canChoose { + if (_field == null || _offset == null) return false; + if (_field!.issue.kind != BackupTimeFieldKind.scheduleOccurrence) { + return true; + } + final proof = _proof; + return proof != null && + proof.conflicts.isEmpty && + (proof.possibleOverlaps.isEmpty || _possibleAcknowledged); + } + + Future _confirm() async { + if (!_canChoose) return; + final owner = _owner, draft = _draft; + final input = _input, field = _field!; + final offset = _offset!; + final acknowledged = + _proof?.possibleOverlaps.map((item) => item.id).toSet() ?? {}; + setState(() { + _busy = true; + }); + final accepted = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + scrollable: true, + title: Text(text('이 시각을 선택할까요?', 'Use this time?')), + content: Text( + '${text('현재 기록', 'Current record')}\n${field.issue.currentLiteral}\n${field.issue.zone ?? text('시간대 미지정', 'No time zone')}\n\n${text('선택할 시각', 'Selected time')}\n${field.draft.civil.toCivilIso8601String()}\n${field.draft.zone} · ${_offsetLabel(offset)}\n\n${text('선택 후 백업 전체를 다시 검증합니다. 현재 앱 데이터는 아직 바뀌지 않습니다.', 'The entire backup will be validated again. Current app data is not replaced yet.')}\n\n${acknowledged.isEmpty ? '' : text('겹침이 불확실한 일정이 있다는 점을 확인했습니다. 남은 시간 문제를 해결해야 복원할 수 있습니다.', 'You acknowledged appointments with uncertain overlap. Remaining time issues must be resolved before restoring.')}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(text('돌아가기', 'Back')), + ), + FilledButton( + key: const Key('confirm-backup-time-choice'), + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(text('선택', 'Choose')), + ), + ], + ), + ); + if (!owns(owner) || draft != _draft) return; + if (accepted != true) { + setState(() { + _busy = false; + }); + return; + } + try { + await input.choose( + BackupTimeChoice( + review: field, + offsetSeconds: offset, + acknowledgedPossibleIds: acknowledged, + ), + ); + if (!owns(owner)) return; + await _validate(); + } catch (_) { + if (!owns(owner)) return; + setState(() { + _busy = false; + _field = null; + _offset = null; + _possibleAcknowledged = false; + _error = text( + '선택이 만료됐거나 저장되지 않았어요. 전체 검증을 다시 해주세요.', + 'The choice expired or could not be saved. Validate again.', + ); + }); + } + } + + Future _validate() async { + final owner = _owner; + final input = _input; + setState(() { + _busy = true; + _error = null; + _clearDraft(); + }); + try { + final selected = await input.revalidate(); + if (!owns(owner)) { + await selected.dispose(); + return; + } + if (selected is BackupRestoreInput) { + _finish(selected); + return; + } + if (selected is! BackupTimeReviewInput) { + await selected.dispose(); + throw const BackupTimeReviewStale(); + } + if (selected != input) { + _retired.add(input); + _input = selected; + } + _cursor = null; + _previous.clear(); + await _load(); + } catch (_) { + if (!owns(owner)) return; + setState(() { + _busy = false; + _error = text( + '전체 검증을 완료하지 못했어요. 다시 검증하거나 취소 후 백업을 다시 선택해주세요.', + 'Full validation did not complete. Retry, or cancel and select the backup again.', + ); + }); + } + } + + Future _recurrence(BackupTimeReviewIssue issue) async { + final callback = widget.onReviewRecurrence; + if (callback == null) return; + final owner = _owner; + setState(() { + _busy = true; + _error = null; + }); + try { + await callback(_input, issue); + if (owns(owner)) await _validate(); + } catch (_) { + if (owns(owner)) { + setState(() { + _busy = false; + _error = text( + '반복 규칙 검토를 완료하지 못했어요.', + 'Recurrence review did not complete.', + ); + }); + } + } + } + + Future _cancel() async { + if (_closing) return; + _owner++; + setState(() { + _closing = true; + _busy = true; + _error = null; + }); + try { + for (final old in List.of(_retired)) { + await old.dispose(); + _retired.remove(old); + } + await _input.dispose(); + if (mounted) _finish(null); + } catch (_) { + if (!mounted) return; + setState(() { + _closing = false; + _busy = false; + _cleanupFailed = true; + _error = text( + '임시 데이터 정리를 완료하지 못했어요. 취소를 다시 눌러 정리를 재시도해주세요.', + 'Temporary data cleanup failed. Press Cancel again to retry cleanup.', + ); + }); + } + } + + void _finish(BackupRestoreInput? ready) { + final owner = ++_owner; + setState(() { + _allowPop = true; + _busy = true; + _closing = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && owner == _owner) { + Navigator.of(context).pop(ready); + } else if (ready != null) { + unawaited(ready.dispose().catchError((Object _) {})); + } + }); + } + + String _kind(BackupTimeFieldKind kind) => switch (kind) { + BackupTimeFieldKind.scheduleOccurrence => text('일정 시각', 'Schedule time'), + BackupTimeFieldKind.scheduleStartedAt => text('시작 기록', 'Start record'), + BackupTimeFieldKind.scheduleFinishedAt => text( + '완료 기록', + 'Completion record', + ), + BackupTimeFieldKind.templateCreatedAt => text( + '템플릿 생성 시각', + 'Template creation time', + ), + BackupTimeFieldKind.templateUpdatedAt => text( + '템플릿 수정 시각', + 'Template update time', + ), + BackupTimeFieldKind.recurrenceRule => text('반복 규칙', 'Recurrence rule'), + }; + + String _reason(BackupTimeIssueReason reason) => switch (reason) { + BackupTimeIssueReason.unknownZone => text( + '시간대를 찾을 수 없어요.', + 'The time zone is unavailable.', + ), + BackupTimeIssueReason.nonexistentCivil => text( + '이 시간대에는 없는 시각이에요.', + 'This local time does not exist.', + ), + BackupTimeIssueReason.repeatedCivil => text( + '두 번 나타나는 시각이에요. 어느 시각인지 선택해주세요.', + 'This time occurs twice. Choose an occurrence.', + ), + BackupTimeIssueReason.changedOffset => text( + '저장된 UTC 오프셋과 현재 규칙이 달라요.', + 'The saved UTC offset differs from current rules.', + ), + BackupTimeIssueReason.missingInstantOffset => text( + '절대시각을 확인하려면 시간대와 발생 시각을 선택해야 해요.', + 'Choose a time zone and occurrence to identify the instant.', + ), + BackupTimeIssueReason.recurrenceOrder => text( + '반복 일정의 이후 회차를 검토해야 해요.', + 'Review the following recurrence occurrences.', + ), + }; + + @override + Widget build(BuildContext context) { + final disabled = _busy || _cleanupFailed; + final issue = _selected; + return PopScope( + canPop: _allowPop, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) _cancel(); + }, + child: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(text('백업 시간 확인', 'Review backup times')), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + text( + '시간 정보를 확인한 뒤 백업 전체를 검증합니다. 이 화면에서는 현재 앱 데이터를 교체하지 않습니다.', + 'Review the time information, then validate the entire backup. This screen does not replace current app data.', + ), + ), + if (_busy) const LinearProgressIndicator(), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + _error!, + key: const Key('backup-time-error'), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + if (_page != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + text( + '확인이 필요한 항목 ${_input.summary.issueCount}개', + '${_input.summary.issueCount} items need review', + ), + ), + ), + for (final item in _page!.items) + Card( + child: ListTile( + key: ValueKey('backup-time-issue-${item.id}'), + isThreeLine: false, + title: Text( + item.name.isEmpty ? _kind(item.kind) : item.name, + ), + subtitle: Text( + '${_kind(item.kind)}\n${_reason(item.reason)}\n${text('백업 원문', 'Original backup value')}: ${item.originalLiteral}\n${text('검토 중 값', 'Current value')}: ${item.currentLiteral}\n${item.zone ?? ''}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: disabled ? null : () => _edit(item), + ), + ), + Wrap( + spacing: 8, + children: [ + if (_previous.isNotEmpty) + TextButton( + key: const Key('backup-time-previous'), + onPressed: disabled + ? null + : () { + _cursor = _previous.removeLast(); + _load(); + }, + child: Text(text('이전 항목', 'Previous items')), + ), + if (_page!.nextCursor != null) + TextButton( + key: const Key('backup-time-next'), + onPressed: disabled + ? null + : () { + _previous.add(_cursor); + _cursor = _page!.nextCursor; + _load(); + }, + child: Text(text('다음 항목', 'Next items')), + ), + ], + ), + ], + if (issue != null) ...[ + const Divider(), + Text(issue.name, style: Theme.of(context).textTheme.titleLarge), + if (issue.kind == BackupTimeFieldKind.recurrenceRule) ...[ + Text( + text( + '반복 규칙은 회차와 제외 기록을 함께 검토해야 합니다.', + 'The rule, occurrences, and exclusions must be reviewed together.', + ), + ), + FilledButton( + key: const Key('backup-time-recurrence'), + onPressed: disabled || widget.onReviewRecurrence == null + ? null + : () => _recurrence(issue), + child: Text(text('반복 규칙 검토', 'Review recurrence')), + ), + ] else ...[ + if (issue.kind == BackupTimeFieldKind.scheduleOccurrence) + Wrap( + spacing: 8, + children: [ + TextButton( + key: const Key('backup-time-date'), + onPressed: disabled ? null : () => _pickCivil(true), + child: Text(text('날짜 선택', 'Choose date')), + ), + TextButton( + key: const Key('backup-time-clock'), + onPressed: disabled ? null : () => _pickCivil(false), + child: Text(text('시간 선택', 'Choose time')), + ), + ], + ), + Text(_civil!.toCivilIso8601String()), + TextButton( + key: const Key('backup-time-zone'), + onPressed: disabled ? null : _pickZone, + child: Text( + _zone.isEmpty + ? text('시간대 선택', 'Choose time zone') + : _zone, + ), + ), + FilledButton( + key: const Key('review-backup-time-field'), + onPressed: disabled || _zone.isEmpty ? null : _reviewField, + child: Text(text('가능한 시각 확인', 'Review occurrences')), + ), + if (_field != null) ...[ + if (_field!.choices.isEmpty) + Text( + text( + '이 시각은 존재하지 않습니다. 제안된 시각을 검토하거나 직접 변경해주세요.', + 'This time does not exist. Review the suggestion or choose another time.', + ), + ), + if (_field!.nextValidCivil != null && + issue.kind == BackupTimeFieldKind.scheduleOccurrence) + TextButton( + key: const Key('backup-time-gap-proposal'), + onPressed: disabled + ? null + : () { + setState(() { + _civil = _field!.nextValidCivil; + _changed(); + }); + }, + child: Text( + '${text('제안 시각 검토', 'Review suggested time')}: ${_field!.nextValidCivil!.toCivilIso8601String()}', + ), + ), + for (final value in _field!.choices) + CheckboxListTile( + key: ValueKey( + 'backup-time-offset-${value.offsetSeconds}', + ), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + '${_offsetLabel(value.offsetSeconds)}\n${value.instantUtc.toIso8601String()}', + ), + value: _offset == value.offsetSeconds, + onChanged: disabled + ? null + : (checked) { + setState(() { + _possibleAcknowledged = false; + _offset = checked == true + ? value.offsetSeconds + : null; + }); + }, + ), + if (_offset != null && + issue.kind == + BackupTimeFieldKind.scheduleOccurrence) ...[ + if (_proof == null) + Text( + text( + '겹침 검증을 완료하지 못했어요. 가능한 시각을 다시 확인해주세요.', + 'Overlap validation is missing. Review occurrences again.', + ), + key: const Key('backup-time-missing-proof'), + ), + if (_proof?.conflicts.isNotEmpty == true) + Text( + '${text('다른 일정 또는 준비 시간과 겹쳐요. 시간을 다시 선택해주세요.', 'This overlaps another appointment or its preparation. Choose another time.')}\n${_proof!.conflicts.map((item) => item.other.schedule.scheduleName).toSet().join(', ')}', + key: const Key('backup-time-known-conflict'), + ), + if (_proof?.possibleOverlaps.isNotEmpty == true) ...[ + Text( + text( + '다음 일정은 정확한 겹침을 아직 확인할 수 없어요.', + 'Exact overlap is still uncertain for these appointments.', + ), + ), + for (final item in _proof!.possibleOverlaps) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + '${item.name}\n${item.originalCivil == null ? '' : CivilDateTime.fromFields(item.originalCivil!).toCivilIso8601String()} · ${item.timeZoneId ?? ''}', + ), + ), + CheckboxListTile( + key: const Key('backup-time-possible-ack'), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: _possibleAcknowledged, + onChanged: disabled + ? null + : (value) => setState( + () => _possibleAcknowledged = value == true, + ), + title: Text( + text( + '겹침이 불확실한 일정이 있음을 확인했어요. 남은 시간 문제를 해결해야 복원할 수 있어요.', + 'I understand that overlap is uncertain. Remaining time issues must be resolved before restoring.', + ), + ), + ), + ], + ], + FilledButton( + key: const Key('choose-backup-time-field'), + onPressed: disabled || !_canChoose ? null : _confirm, + child: Text(text('이 시각 선택', 'Choose this occurrence')), + ), + ], + ], + ], + const SizedBox(height: 16), + OutlinedButton( + key: const Key('revalidate-backup-time'), + onPressed: disabled ? null : _validate, + child: Text(text('전체 다시 검증', 'Validate entire backup')), + ), + TextButton( + key: const Key('cancel-backup-time'), + onPressed: _closing ? null : _cancel, + child: Text(text('취소', 'Cancel')), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart b/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart index 1bc24fbf..d9bf7a39 100644 --- a/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart +++ b/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart @@ -52,6 +52,7 @@ class MonthlySchedulesBloc final LoadSchedulesForMonthUseCase _loadSchedulesForMonthUseCase; final GetSchedulesByDateUseCase _getSchedulesByDateUseCase; final DeleteScheduleUseCase _deleteScheduleUseCase; + DeleteScheduleUseCase get deletions => _deleteScheduleUseCase; final LoadPreparationByScheduleIdUseCase _loadPreparationByScheduleIdUseCase; final GetPreparationByScheduleIdUseCase _getPreparationByScheduleIdUseCase; final StreamPreparationsUseCase _streamPreparationsUseCase; diff --git a/lib/presentation/calendar/component/schedule_deletion_dialog.dart b/lib/presentation/calendar/component/schedule_deletion_dialog.dart new file mode 100644 index 00000000..b8096a29 --- /dev/null +++ b/lib/presentation/calendar/component/schedule_deletion_dialog.dart @@ -0,0 +1,246 @@ +import 'dart:async'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; + +import 'package:flutter/material.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_scope_sheet.dart'; + +Future showScheduleDeletionDialog( + BuildContext context, { + required ScheduleEntity schedule, + required DeleteScheduleUseCase deletions, + RecurringEditScope? scope, +}) async { + var selectedScope = scope ?? RecurringEditScope.occurrence; + if (scope == null && + schedule.isRecurring && + !schedule.isStarted && + !schedule.preparationFrozen && + schedule.doneStatus == ScheduleDoneStatus.notEnded && + (ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now(), + ).instantUtc?.isAfter(DateTime.now().toUtc()) ?? + false)) { + final selected = await showRecurrenceScope(context, deleting: true); + if (selected == null || !context.mounted) return false; + selectedScope = selected; + } + if (!context.mounted) return false; + return await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ScheduleDeletionDialog( + scheduleId: schedule.id, + scope: selectedScope, + deletions: deletions, + ), + ) ?? + false; +} + +class _ScheduleDeletionDialog extends StatefulWidget { + const _ScheduleDeletionDialog({ + required this.scheduleId, + required this.scope, + required this.deletions, + }); + final String scheduleId; + final RecurringEditScope scope; + final DeleteScheduleUseCase deletions; + @override + State<_ScheduleDeletionDialog> createState() => + _ScheduleDeletionDialogState(); +} + +class _ScheduleDeletionDialogState extends State<_ScheduleDeletionDialog> { + ScheduleDeletionIntent? _intent; + ScheduleDeletionCommit? _commit; + ScheduleDeletionResult? _result; + Object? _failure; + bool _working = false; + bool _loading = true; + bool _delayed = false; + Timer? _delay; + late final int _generation; + @override + void initState() { + super.initState(); + _generation = widget.deletions.currentGeneration; + _prepare(); + } + + @override + void dispose() { + _delay?.cancel(); + super.dispose(); + } + + Future _prepare() async { + try { + final intent = await widget.deletions.prepare( + widget.scheduleId, + scope: widget.scope, + ); + if (mounted && _current) { + setState(() { + _intent = intent; + _loading = false; + }); + } + } catch (error) { + if (mounted && _current) { + setState(() { + _failure = error; + _loading = false; + }); + } + } + } + + bool get _current => widget.deletions.isCurrentGeneration(_generation); + Future _delete() async { + if (_working || _intent == null || !_current) return; + setState(() { + _working = true; + _failure = null; + _delayed = false; + }); + _delay = Timer(const Duration(seconds: 10), () { + if (mounted && _current) setState(() => _delayed = true); + }); + try { + final result = _commit == null + ? await widget.deletions.confirm( + _intent!, + onCommitted: (commit) { + if (mounted && + widget.deletions.isCurrentGeneration(commit.generation)) { + setState(() => _commit = commit); + } + }, + ) + : await widget.deletions.retryCleanup(_commit!); + if (mounted && + widget.deletions.isCurrentGeneration(result.commit.generation)) { + setState(() { + _commit = result.commit; + _result = result; + }); + } + } catch (error) { + if (mounted && _current) setState(() => _failure = error); + } finally { + _delay?.cancel(); + if (mounted && _current) setState(() => _working = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final commit = _commit; + final failed = _failure != null && commit == null; + final protected = + _failure is ScheduleDeletionRejected && + (_failure as ScheduleDeletionRejected).failure == + ScheduleDeletionFailure.protected; + final complete = _result?.cleanup == ScheduleDeletionCleanup.complete; + final title = (commit?.alreadyAbsent ?? false) + ? l10n.scheduleDeletionAbsentTitle + : commit != null + ? l10n.scheduleDeletionRemovedTitle + : failed + ? l10n.scheduleDeleteFailedTitle + : l10n.scheduleDeleteConfirmTitle; + final description = commit != null + ? complete + ? commit.alreadyAbsent + ? l10n.scheduleDeletionAlreadyAbsent + : l10n.scheduleDeletionComplete + : _working && !_delayed + ? l10n.scheduleDeletionCleaning + : l10n.scheduleDeletionPending + : failed + ? protected + ? l10n.scheduleDeletionPreparationActive + : l10n.scheduleDeleteFailedDescription + : widget.scope == RecurringEditScope.following + ? l10n.scheduleDeletionFollowingConsequences + : l10n.scheduleDeletionConsequences; + final schedule = _intent?.snapshot.schedule; + return PopScope( + canPop: !_working, + child: AlertDialog( + scrollable: true, + title: Text(title), + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + buttonPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + actionsOverflowButtonSpacing: 8, + content: Semantics( + liveRegion: true, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_loading) const LinearProgressIndicator(), + if (schedule != null && commit == null) ...[ + Text( + schedule.scheduleName, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Text( + widget.scope == RecurringEditScope.following + ? l10n.scheduleDeletionFollowing + : l10n.scheduleDeletionOnlySelected, + ), + const SizedBox(height: 12), + ScheduleZonedTime( + civil: CivilDateTime.fromFields(schedule.scheduleTime), + timeZoneId: schedule.timeZoneId, + resolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now(), + ), + ), + const SizedBox(height: 12), + ], + if (!_loading) Text(description), + if ((commit?.alreadyAbsent ?? false) && !complete) + Text(l10n.scheduleDeletionAlreadyAbsent), + if (_working) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + ], + ), + ), + actions: [ + if (_intent != null && !failed && commit == null) + TextButton( + onPressed: _working ? null : _delete, + child: Text(l10n.deleteScheduleConfirmAction), + ), + if (commit != null && !complete && _current) + TextButton( + onPressed: _working ? null : _delete, + child: Text(l10n.scheduleDeletionRetryCleanup), + ), + TextButton( + onPressed: _working && commit == null + ? null + : () => Navigator.of(context).pop(commit != null), + child: Text(commit != null || failed ? l10n.ok : l10n.cancel), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/calendar/component/schedule_detail.dart b/lib/presentation/calendar/component/schedule_detail.dart index 00b6373e..9741c7cb 100644 --- a/lib/presentation/calendar/component/schedule_detail.dart +++ b/lib/presentation/calendar/component/schedule_detail.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:on_time_front/presentation/recurring/recurrence_occurrence_sheet.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:flutter/material.dart'; @@ -6,7 +9,6 @@ import 'package:flutter_swipe_action_cell/core/cell.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/shared/utils/duration_format.dart'; -import 'package:intl/intl.dart'; // Helper widget for swipe actions class _SwipeActionContent extends StatelessWidget { @@ -103,11 +105,9 @@ class _ScheduleDetailState extends State { List _buildSwipeActions(BuildContext context) { final theme = Theme.of(context); final now = DateTime.now(); - final canEdit = - widget.schedule.doneStatus == ScheduleDoneStatus.notEnded && - !_hasPreparationStarted(now) && - !widget.schedule.occurrenceInstantUtc.isBefore(now.toUtc()); - final canDelete = widget.schedule.doneStatus == ScheduleDoneStatus.notEnded; + final canEdit = _canEdit(now); + const canDelete = + true; // Eligibility is checked from current data in confirmation. return [ if (canDelete) SwipeAction( @@ -140,6 +140,24 @@ class _ScheduleDetailState extends State { ]; } + bool _canEdit(DateTime now) { + if (widget.schedule.isStarted || + widget.isEarlyStarted || + widget.schedule.preparationFrozen || + widget.schedule.doneStatus != ScheduleDoneStatus.notEnded || + widget.schedule.retainedRecurringReference) { + return false; + } + final time = ScheduleTimeResolver.resolve(widget.schedule, nowUtc: now); + if (time.instantUtc == null) { + return true; // Keep correction/explanation reachable. + } + return widget.schedule.doneStatus == ScheduleDoneStatus.notEnded && + !widget.schedule.retainedRecurringReference && + !_hasPreparationStarted(now) && + !time.instantUtc!.isBefore(now.toUtc()); + } + bool _hasPreparationStarted(DateTime now) { if (widget.schedule.isStarted || widget.isEarlyStarted) { return true; @@ -150,7 +168,12 @@ class _ScheduleDetailState extends State { return false; } - final preparationStartTime = widget.schedule.occurrenceInstantUtc.subtract( + final instant = ScheduleTimeResolver.resolve( + widget.schedule, + nowUtc: now, + ).instantUtc; + if (instant == null) return false; + final preparationStartTime = instant.subtract( widget.schedule.moveTime + preparationTime + (widget.schedule.scheduleSpareTime ?? Duration.zero), @@ -160,48 +183,45 @@ class _ScheduleDetailState extends State { Widget _buildScheduleContent(BuildContext context) { final theme = Theme.of(context); + final time = Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: _ScheduleTimeColumn(scheduleTime: widget.schedule.scheduleTime), + ); + final details = _ScheduleDetailsColumn( + schedule: widget.schedule, + placeName: widget.schedule.place.placeName, + preparationTime: widget.preparationTime, + onEdit: _canEdit(DateTime.now()) ? widget.onEdit : null, + onDelete: widget.onDeleted, + ); return Container( decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(16), ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: _ScheduleTimeColumn( - scheduleTime: widget.schedule.scheduleTime, - ), - ), - const _VerticalDivider(), - Expanded( - child: _ScheduleDetailsColumn( - schedule: widget.schedule, - placeName: widget.schedule.place.placeName, - preparationTime: widget.preparationTime, - onEdit: - widget.schedule.doneStatus == - ScheduleDoneStatus.notEnded && - !_hasPreparationStarted(DateTime.now()) && - !widget.schedule.occurrenceInstantUtc.isBefore( - DateTime.now().toUtc(), - ) - ? widget.onEdit - : null, - onDelete: - widget.schedule.doneStatus == ScheduleDoneStatus.notEnded - ? widget.onDeleted - : null, - ), - ), - ], - ), - ), + padding: const EdgeInsets.symmetric(vertical: 8), + child: LayoutBuilder( + builder: (context, constraints) { + final stacked = + constraints.maxWidth < 400 || + MediaQuery.textScalerOf(context).scale(16) > 20; + if (stacked) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [time, const Divider(), details], + ); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + time, + const _VerticalDivider(), + Expanded(child: details), + ], + ), + ); + }, ), ); } @@ -215,24 +235,15 @@ class _ScheduleTimeColumn extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final locale = AppLocalizations.of(context)!.localeName; return Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - DateFormat.Hm(locale).format(scheduleTime), + TimeOfDay.fromDateTime(scheduleTime).format(context), style: theme.textTheme.titleSmall, textAlign: TextAlign.center, ), - const SizedBox(height: 4), - Text( - DateFormat('a', 'en').format(scheduleTime), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - textAlign: TextAlign.center, - ), ], ); } @@ -256,6 +267,10 @@ class _ScheduleDetailsColumn extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final resolution = ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now(), + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), child: Theme( @@ -271,107 +286,142 @@ class _ScheduleDetailsColumn extends StatelessWidget { ), child: IconTheme( data: IconTheme.of(context).copyWith(size: 32), - child: ExpansionTile( - shape: const Border(), - collapsedShape: const Border(), - iconColor: - theme.colorScheme.onSurfaceVariant, // Color when expanded - // icon size provided by IconTheme above - collapsedIconColor: - theme.colorScheme.onSurfaceVariant, // Color when collapsed - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExpansionTile( + shape: const Border(), + collapsedShape: const Border(), + iconColor: + theme.colorScheme.onSurfaceVariant, // Color when expanded + // icon size provided by IconTheme above + collapsedIconColor: + theme.colorScheme.onSurfaceVariant, // Color when collapsed + title: Text( schedule.scheduleName, style: theme.textTheme.titleLarge, maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (schedule.isRecurring) - Text( - recurrenceText(context, '반복 일정', 'Recurring schedule'), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), - if (schedule.isRecurring && - !schedule.isStarted && - schedule.doneStatus == ScheduleDoneStatus.notEnded && - schedule.occurrenceInstantUtc.isBefore( - DateTime.now().toUtc(), - )) - Text( - recurrenceText( - context, - '진행 기록 없음', - 'No preparation recorded', - ), - style: theme.textTheme.bodySmall, + childrenPadding: const EdgeInsets.only(top: 16.0), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + const _MapPinFillSvg(), + const SizedBox(width: 4), + Expanded( + child: Text( + placeName, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - ], - ), - childrenPadding: const EdgeInsets.only(top: 16.0), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Row( + ), children: [ - const _MapPinFillSvg(), - const SizedBox(width: 4), - Expanded( - child: Text( - placeName, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, + if (onDelete != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + label: Text( + AppLocalizations.of( + context, + )!.deleteScheduleConfirmAction, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - ], - ), - ), - children: [ - if (schedule.isRecurring) - TextButton( - onPressed: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .94, - child: RecurrenceOccurrenceSheet( - schedule: schedule, - onEdit: onEdit, - onDelete: onDelete, + if (schedule.isRecurring) + TextButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .94, + child: RecurrenceOccurrenceSheet( + schedule: schedule, + onEdit: onEdit, + onDelete: onDelete, + ), + ), + ), + child: Text( + recurrenceText(context, '이번 회차 보기', 'View occurrence'), ), ), + Column( + children: [ + _ScheduleInfoTile( + label: AppLocalizations.of(context)!.travelTime, + value: formatDuration(context, schedule.moveTime), + ), + _ScheduleInfoTile( + label: AppLocalizations.of(context)!.preparationTime, + value: preparationTime == null + ? '-' + : formatDuration(context, preparationTime!), + ), + _ScheduleInfoTile( + label: AppLocalizations.of(context)!.spareTime, + value: formatDuration( + context, + schedule.scheduleSpareTime ?? Duration.zero, + ), + ), + ], ), - child: Text( - recurrenceText(context, '이번 회차 보기', 'View occurrence'), + ], + ), + ScheduleZonedTime( + civil: CivilDateTime.fromFields(schedule.scheduleTime), + timeZoneId: schedule.timeZoneId, + resolution: resolution, + style: theme.textTheme.bodySmall, + ), + if (resolution.instantUtc == null) + Text( + recurrenceText( + context, + '시간대와 발생 시각을 확인해주세요.', + 'Review the time zone and occurrence.', ), + style: theme.textTheme.bodySmall, ), - Column( - children: [ - _ScheduleInfoTile( - label: AppLocalizations.of(context)!.travelTime, - value: formatDuration(context, schedule.moveTime), + if (schedule.retainedRecurringReference) + Text( + recurrenceText( + context, + '이전 반복 구간의 보존 기록', + 'Retained record from a closed recurrence', ), - _ScheduleInfoTile( - label: AppLocalizations.of(context)!.preparationTime, - value: preparationTime == null - ? '-' - : formatDuration(context, preparationTime!), + style: theme.textTheme.bodySmall, + ), + if (schedule.isRecurring) + Text( + recurrenceText(context, '반복 일정', 'Recurring schedule'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, ), - _ScheduleInfoTile( - label: AppLocalizations.of(context)!.spareTime, - value: formatDuration( - context, - schedule.scheduleSpareTime ?? Duration.zero, - ), + ), + if (schedule.isRecurring && + !schedule.isStarted && + schedule.doneStatus == ScheduleDoneStatus.notEnded && + resolution.isHistorical) + Text( + recurrenceText( + context, + '진행 기록 없음', + 'No preparation recorded', ), - ], - ), + style: theme.textTheme.bodySmall, + ), ], ), ), @@ -391,33 +441,22 @@ class _ScheduleInfoTile extends StatelessWidget { final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; - return Column( - children: [ - SizedBox( - width: double.infinity, - height: 22.18, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 18, - children: [ - Text( - label, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.outline, - ), - ), - Text( - value, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - ), - ), - ], + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Wrap( + spacing: 18, + runSpacing: 4, + children: [ + Text( + label, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.outline), ), - ), - ], + Text( + value, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface), + ), + ], + ), ); } } diff --git a/lib/presentation/calendar/screens/calendar_screen.dart b/lib/presentation/calendar/screens/calendar_screen.dart index cc53c716..10daa77e 100644 --- a/lib/presentation/calendar/screens/calendar_screen.dart +++ b/lib/presentation/calendar/screens/calendar_screen.dart @@ -1,5 +1,6 @@ +import 'package:on_time_front/presentation/calendar/component/schedule_deletion_dialog.dart'; +import 'package:on_time_front/presentation/shared/time/date_basis_info.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; -import 'package:on_time_front/domain/use-cases/recurring_schedules_use_case.dart'; import 'package:on_time_front/presentation/recurring/recurrence_scope_sheet.dart'; import 'dart:async'; @@ -17,7 +18,6 @@ import 'package:on_time_front/presentation/schedule_create/screens/schedule_crea import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; import 'package:on_time_front/presentation/shared/components/calendar/centered_calendar_header.dart'; import 'package:on_time_front/presentation/shared/components/calendar/schedule_marker_builder.dart'; -import 'package:on_time_front/presentation/shared/components/two_button_delete_dialog.dart'; import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; import 'package:on_time_front/presentation/shared/theme/calendar_theme.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; @@ -158,17 +158,21 @@ class _CalendarScreenState extends State { _refreshSchedulesIfSaved(saved); } - Future _deleteRecurring( + final _deleting = {}; + Future _deleteSchedule( BuildContext context, ScheduleEntity schedule, ) async { - final scope = await showRecurrenceScope(context, deleting: true); - if (scope == null || !context.mounted) return; + if (!_deleting.add(schedule.id)) return; try { - await getIt().delete(schedule, scope); - _refreshSchedulesIfSaved(true); - } catch (_) { - if (context.mounted) await _showScheduleDeleteFailureDialog(context); + final changed = await showScheduleDeletionDialog( + context, + schedule: schedule, + deletions: _monthlySchedulesBloc.deletions, + ); + if (changed && context.mounted) _refreshSchedulesIfSaved(true); + } finally { + _deleting.remove(schedule.id); } } @@ -430,6 +434,7 @@ class _CalendarScreenState extends State { textAlign: TextAlign.start, ), ), + const DateBasisInfo(home: false), SizedBox(height: selectedDateHeadingGap), Expanded( child: @@ -448,40 +453,11 @@ class _CalendarScreenState extends State { context, schedule: schedule, ), - onDeleteSchedule: (schedule) { - if (schedule.isRecurring) { - _deleteRecurring( + onDeleteSchedule: (schedule) => + _deleteSchedule( context, schedule, - ); - return; - } - showTwoButtonDeleteDialog( - context, - title: AppLocalizations.of( - context, - )!.scheduleDeleteConfirmTitle, - description: AppLocalizations.of( - context, - )!.scheduleDeleteConfirmDescription, - cancelText: AppLocalizations.of( - context, - )!.cancel, - confirmText: AppLocalizations.of( - context, - )!.deleteScheduleConfirmAction, - ).then((confirmed) { - if (confirmed != true || - !context.mounted) { - return; - } - _monthlySchedulesBloc.add( - MonthlySchedulesScheduleDeleted( - schedule: schedule, - ), - ); - }); - }, + ), ); }, ), diff --git a/lib/presentation/home/bloc/weekly_schedules_bloc.dart b/lib/presentation/home/bloc/weekly_schedules_bloc.dart index e8240924..08db8e95 100644 --- a/lib/presentation/home/bloc/weekly_schedules_bloc.dart +++ b/lib/presentation/home/bloc/weekly_schedules_bloc.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/core/time/device_civil_day.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -27,7 +30,14 @@ class WeeklySchedulesBloc } await emit.forEach( - _getSchedulesByDateUseCase(event.startDate, event.endDate), + _getSchedulesByDateUseCase( + CivilDateTime.fromFields( + event.startDate, + ).toUtcCarrier().subtract(const Duration(days: 2)), + CivilDateTime.fromFields( + event.endDate, + ).toUtcCarrier().add(const Duration(days: 2)), + ), onData: (schedules) => state.copyWith( status: () => WeeklySchedulesStatus.success, schedules: () => schedules, diff --git a/lib/presentation/home/bloc/weekly_schedules_state.dart b/lib/presentation/home/bloc/weekly_schedules_state.dart index d5c56d12..42f04fdc 100644 --- a/lib/presentation/home/bloc/weekly_schedules_state.dart +++ b/lib/presentation/home/bloc/weekly_schedules_state.dart @@ -13,17 +13,30 @@ final class WeeklySchedulesState extends Equatable { List get dates => schedules.map((schedule) => schedule.scheduleTime).toList(); - ScheduleEntity? get todaySchedule => schedules - .where((schedule) { - if (schedule.doneStatus != ScheduleDoneStatus.notEnded) return false; + ScheduleEntity? get todaySchedule => todayScheduleAt(DateTime.now()); - final now = DateTime.now(); - return schedule.scheduleTime.year == now.year && - schedule.scheduleTime.month == now.month && - schedule.scheduleTime.day == now.day; - }) - .sortedBy((e) => e.scheduleTime) - .firstOrNull; + ScheduleEntity? todayScheduleAt(DateTime now) { + final day = DeviceCivilDay.at(now); + final candidates = <(ScheduleEntity, DateTime)>[]; + for (final schedule in schedules) { + if (schedule.doneStatus != ScheduleDoneStatus.notEnded || + schedule.retainedRecurringReference) { + continue; + } + final instant = ScheduleTimeResolver.resolve( + schedule, + nowUtc: now, + ).instantUtc; + if (instant != null && day.contains(instant)) { + candidates.add((schedule, instant)); + } + } + candidates.sort((a, b) { + final compared = a.$2.compareTo(b.$2); + return compared == 0 ? a.$1.id.compareTo(b.$1.id) : compared; + }); + return candidates.firstOrNull?.$1; + } WeeklySchedulesState copyWith({ WeeklySchedulesStatus Function()? status, diff --git a/lib/presentation/home/components/device_day_refresh.dart b/lib/presentation/home/components/device_day_refresh.dart new file mode 100644 index 00000000..3523aa25 --- /dev/null +++ b/lib/presentation/home/components/device_day_refresh.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:on_time_front/core/time/device_civil_day.dart'; + +/// Refreshes device-day displays independently of durable schedule changes. +class DeviceDayRefresh extends StatefulWidget { + const DeviceDayRefresh({super.key, required this.builder, this.now}); + final Widget Function(BuildContext context, DateTime now) builder; + final DateTime Function()? now; + + @override + State createState() => _DeviceDayRefreshState(); +} + +class _DeviceDayRefreshState extends State + with WidgetsBindingObserver { + late DateTime _displayNow; + Timer? _midnight; + Timer? _zoneCheck; + DateTime _now() => (widget.now?.call() ?? DateTime.now()).toLocal(); + (int, int, int, Duration, String) _signature(DateTime value) => ( + value.year, + value.month, + value.day, + value.timeZoneOffset, + value.timeZoneName, + ); + + @override + void initState() { + super.initState(); + _displayNow = _now(); + WidgetsBinding.instance.addObserver(this); + _armMidnight(); + _zoneCheck = Timer.periodic(const Duration(minutes: 1), (_) => _refresh()); + } + + void _armMidnight() { + _midnight?.cancel(); + final now = _now(); + _midnight = Timer( + DeviceCivilDay.at(now).endUtc.difference(now.toUtc()), + () { + _refresh(force: true); + }, + ); + } + + void _refresh({bool force = false}) { + if (!mounted) return; + final now = _now(); + if (!force && _signature(now) == _signature(_displayNow)) return; + setState(() => _displayNow = now); + _armMidnight(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _refresh(force: true); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _midnight?.cancel(); + _zoneCheck?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _displayNow); +} diff --git a/lib/presentation/home/components/month_calendar.dart b/lib/presentation/home/components/month_calendar.dart index 009cb74e..eed241b5 100644 --- a/lib/presentation/home/components/month_calendar.dart +++ b/lib/presentation/home/components/month_calendar.dart @@ -15,6 +15,7 @@ class MonthCalendar extends StatefulWidget { this.rowHeight = 50, this.daysOfWeekHeight = 40, this.contentPadding = const EdgeInsets.all(16.0), + this.today, }); final MonthlySchedulesState monthlySchedulesState; @@ -23,6 +24,7 @@ class MonthCalendar extends StatefulWidget { final double rowHeight; final double daysOfWeekHeight; final EdgeInsetsGeometry contentPadding; + final DateTime? today; @override State createState() => _MonthCalendarState(); @@ -31,10 +33,12 @@ class MonthCalendar extends StatefulWidget { class _MonthCalendarState extends State { late DateTime _focusedDay; late DateTime _selectedDay; + bool _manuallyNavigated = false; + DateTime get _today => widget.today ?? DateTime.now(); DateTime get _firstDay => DateTime(2024, 1, 1); - DateTime get _lastDay => DateTime(DateTime.now().year + 5, 12, 31); + DateTime get _lastDay => DateTime(_today.year + 5, 12, 31); DateTime _clampDay(DateTime day, DateTime firstDay, DateTime lastDay) { final d = DateTime(day.year, day.month, day.day); @@ -49,11 +53,33 @@ class _MonthCalendarState extends State { @override void initState() { super.initState(); - _focusedDay = _clampDay(DateTime.now(), _firstDay, _lastDay); + _focusedDay = _clampDay(_today, _firstDay, _lastDay); _selectedDay = _focusedDay; } + @override + void didUpdateWidget(covariant MonthCalendar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.today == null || + isSameDay(oldWidget.today, widget.today) || + _manuallyNavigated) { + return; + } + final oldMonth = (_focusedDay.year, _focusedDay.month); + _focusedDay = _clampDay(_today, _firstDay, _lastDay); + _selectedDay = _focusedDay; + if (oldMonth != (_focusedDay.year, _focusedDay.month) && + widget.dispatchBlocEvents) { + context.read().add( + MonthlySchedulesMonthAdded( + date: DateTime(_focusedDay.year, _focusedDay.month, 1), + ), + ); + } + } + void _onLeftArrowTap() { + _manuallyNavigated = true; final nextFocusedDay = DateTime(_focusedDay.year, _focusedDay.month - 1, 1); final clampedFocusedDay = _clampDay(nextFocusedDay, _firstDay, _lastDay); @@ -71,6 +97,7 @@ class _MonthCalendarState extends State { } void _onRightArrowTap() { + _manuallyNavigated = true; final nextFocusedDay = DateTime(_focusedDay.year, _focusedDay.month + 1, 1); final clampedFocusedDay = _clampDay(nextFocusedDay, _firstDay, _lastDay); @@ -115,6 +142,7 @@ class _MonthCalendarState extends State { rowHeight: constrainedRowHeight, availableGestures: AvailableGestures.none, focusedDay: _focusedDay, + currentDay: _today, selectedDayPredicate: (day) => isSameDay(_selectedDay, day), firstDay: _firstDay, lastDay: _lastDay, @@ -124,6 +152,7 @@ class _MonthCalendarState extends State { daysOfWeekHeight: widget.daysOfWeekHeight, calendarStyle: calendarTheme.calendarStyle, onDaySelected: (selectedDay, focusedDay) { + _manuallyNavigated = true; final clampedSelectedDay = _clampDay( selectedDay, _firstDay, diff --git a/lib/presentation/home/components/nearest_schedule_query_status.dart b/lib/presentation/home/components/nearest_schedule_query_status.dart new file mode 100644 index 00000000..767ba743 --- /dev/null +++ b/lib/presentation/home/components/nearest_schedule_query_status.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +/// Query progress is not an empty calendar or permission to start preparation. +class NearestScheduleQueryStatus extends StatelessWidget { + const NearestScheduleQueryStatus({ + super.key, + required this.query, + this.showStaleNotice = true, + }); + + final NearestScheduleQuery query; + final bool showStaleNotice; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final key = query.key; + final limited = query is NearestQueryLimited + ? query as NearestQueryLimited + : null; + final loading = query is NearestQueryLoading; + final failed = query is NearestQueryError; + final showCalendar = limited != null || query.issues.isNotEmpty; + final String? message; + if (loading || query is NearestQueryIdle) { + message = l10n.homeCheckingAppointments; + } else if (failed) { + message = l10n.homeQueryFailed; + } else if (limited != null) { + message = switch (limited.reason) { + NearestQueryLimitReason.cancelled => l10n.homeQueryCancelled, + NearestQueryLimitReason.interrupted => l10n.homeQueryInterrupted, + _ => l10n.homeQueryLimited, + }; + } else { + message = null; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (message != null) Semantics(liveRegion: true, child: Text(message)), + if (showStaleNotice && query.stale != null) Text(l10n.homeQueryStale), + if (query.issues.isNotEmpty) Text(l10n.homeTimeIssues), + Wrap( + spacing: 8, + children: [ + if (key != null && loading) + TextButton.icon( + key: const Key('cancel-nearest-query'), + onPressed: () => context.read().add( + ScheduleNearestQueryCancelRequested(key), + ), + icon: const Icon(Icons.close), + label: Text(l10n.homeCancelQuery), + ), + if (key != null && (failed || limited?.canRetry == true)) + TextButton.icon( + key: const Key('retry-upcoming-schedule'), + onPressed: () => context.read().add( + ScheduleNearestQueryRetryRequested(key), + ), + icon: const Icon(Icons.refresh), + label: Text(l10n.homeRetryQuery), + ), + if (key != null && limited?.canContinue == true) + TextButton.icon( + key: const Key('continue-nearest-query'), + onPressed: () => context.read().add( + ScheduleNearestQueryContinueRequested(key), + ), + icon: const Icon(Icons.play_arrow), + label: Text(l10n.homeContinueQuery), + ), + if (showCalendar) + TextButton.icon( + key: const Key('nearest-query-calendar'), + onPressed: () => context.go('/calendar'), + icon: const Icon(Icons.calendar_month_outlined), + label: Text(l10n.homeReviewCalendar), + ), + ], + ), + ], + ); + } +} diff --git a/lib/presentation/home/components/todays_schedule_tile.dart b/lib/presentation/home/components/todays_schedule_tile.dart index b9caba31..6ea738ed 100644 --- a/lib/presentation/home/components/todays_schedule_tile.dart +++ b/lib/presentation/home/components/todays_schedule_tile.dart @@ -1,5 +1,7 @@ +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,11 +13,15 @@ class TodaysScheduleTile extends StatelessWidget { this.schedule, this.onTap, this.compact = false, + this.resolution, + this.emptyLabel, }); final ScheduleEntity? schedule; final VoidCallback? onTap; final bool compact; + final ScheduleTimeResolution? resolution; + final String? emptyLabel; Widget _noSchedule(BuildContext context) { final theme = Theme.of(context); @@ -25,13 +31,11 @@ class TodaysScheduleTile extends StatelessWidget { vertical: compact ? 10.0 : 16.0, ), child: Text( - AppLocalizations.of(context)!.noAppointments, + emptyLabel ?? AppLocalizations.of(context)!.noAppointments, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.outlineVariant, height: 22 / 16, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ); } @@ -39,35 +43,58 @@ class TodaysScheduleTile extends StatelessWidget { Widget _scheduleExists(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: compact ? 10.0 : 16.0), - child: _ScheduleLeftTimeColumn( - scheduleTime: schedule!.scheduleTime, + final resolution = + this.resolution ?? + ScheduleTimeResolver.resolve(schedule!, nowUtc: DateTime.now()); + + final countdown = Padding( + padding: EdgeInsets.symmetric(horizontal: compact ? 10.0 : 16.0), + child: resolution.instantUtc == null + ? const Icon(Icons.schedule_outlined) + : _ScheduleLeftTimeColumn( + key: ValueKey(resolution.instantUtc), + scheduleTime: resolution.instantUtc!, compact: compact, ), - ), - VerticalDivider(width: 1, color: colorScheme.primary), - Expanded( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: compact ? 12.0 : 21.0, - vertical: compact ? 8.0 : 11.0, - ), - child: _ScheduleDetailsColumn( - schedule: schedule!, - compact: compact, - ), - ), - ), - ], + ); + final details = Padding( + padding: EdgeInsets.symmetric( + horizontal: compact ? 12.0 : 21.0, + vertical: compact ? 8.0 : 11.0, + ), + child: _ScheduleDetailsColumn( + schedule: schedule!, + resolution: resolution, + compact: compact, ), ); + return LayoutBuilder( + builder: (context, constraints) { + final stacked = + constraints.maxWidth < 400 || + MediaQuery.textScalerOf(context).scale(16) > 20; + if (stacked) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding(padding: const EdgeInsets.only(top: 8), child: countdown), + Divider(height: 16, color: colorScheme.primary), + details, + ], + ); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + countdown, + VerticalDivider(width: 1, color: colorScheme.primary), + Expanded(child: details), + ], + ), + ); + }, + ); } @override @@ -95,9 +122,14 @@ class TodaysScheduleTile extends StatelessWidget { } class _ScheduleDetailsColumn extends StatelessWidget { - const _ScheduleDetailsColumn({required this.schedule, required this.compact}); + const _ScheduleDetailsColumn({ + required this.schedule, + required this.resolution, + required this.compact, + }); final ScheduleEntity schedule; + final ScheduleTimeResolution resolution; final bool compact; @override @@ -105,10 +137,6 @@ class _ScheduleDetailsColumn extends StatelessWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final textTheme = theme.textTheme; - final formattedTime = DateFormat.jm( - AppLocalizations.of(context)!.localeName, - ).format(schedule.scheduleTime); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -119,12 +147,12 @@ class _ScheduleDetailsColumn extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - //time PM H:MM - Text( - formattedTime, + + ScheduleZonedTime( + civil: CivilDateTime.fromFields(schedule.scheduleTime), + timeZoneId: schedule.timeZoneId, + resolution: resolution, style: textTheme.bodySmall?.copyWith(color: colorScheme.primary), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ], ); @@ -133,6 +161,7 @@ class _ScheduleDetailsColumn extends StatelessWidget { class _ScheduleLeftTimeColumn extends StatelessWidget { const _ScheduleLeftTimeColumn({ + super.key, required this.scheduleTime, required this.compact, }); diff --git a/lib/presentation/home/screens/home_screen_tmp.dart b/lib/presentation/home/screens/home_screen_tmp.dart index 8713a268..abd4cb5b 100644 --- a/lib/presentation/home/screens/home_screen_tmp.dart +++ b/lib/presentation/home/screens/home_screen_tmp.dart @@ -1,4 +1,7 @@ +import 'package:on_time_front/presentation/shared/time/date_basis_info.dart'; import 'dart:math' as math; +import 'package:on_time_front/presentation/home/components/device_day_refresh.dart'; +import 'package:on_time_front/presentation/home/utils/home_schedule_card_selection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -8,6 +11,7 @@ import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; +import 'package:on_time_front/presentation/home/components/nearest_schedule_query_status.dart'; import 'package:on_time_front/presentation/home/utils/today_tile_navigation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/presentation/shared/components/arc_indicator.dart'; @@ -21,34 +25,35 @@ class HomeScreenTmp extends StatelessWidget { @override Widget build(BuildContext context) { - final dateOfToday = DateTime( - DateTime.now().year, - DateTime.now().month, - DateTime.now().day, - 0, - 0, - 0, - ); - - return BlocProvider( - create: (context) => - getIt.get() - ..add(MonthlySchedulesSubscriptionRequested(date: dateOfToday)), - child: BlocBuilder( - builder: (context, state) { - return HomeScreenContent(state: state); - }, - ), + return DeviceDayRefresh( + builder: (context, now) { + final dateOfToday = DateTime(now.year, now.month, now.day); + return BlocProvider( + create: (context) => + getIt.get() + ..add(MonthlySchedulesSubscriptionRequested(date: dateOfToday)), + child: BlocBuilder( + builder: (context, state) => + HomeScreenContent(state: state, now: now), + ), + ); + }, ); } } /// The actual home screen content that can be tested independently class HomeScreenContent extends StatelessWidget { - const HomeScreenContent({super.key, required this.state, this.userScore}); + const HomeScreenContent({ + super.key, + required this.state, + this.userScore, + this.now, + }); final MonthlySchedulesState state; final double? userScore; + final DateTime? now; @override Widget build(BuildContext context) { @@ -60,7 +65,8 @@ class HomeScreenContent extends StatelessWidget { return BlocListener( listenWhen: (previous, current) { return previous.status != ScheduleStatus.started && - current.status == ScheduleStatus.started; + current.status == ScheduleStatus.started && + !current.isResumedPreparation; }, listener: (context, scheduleState) { context.go('/scheduleStart'); @@ -73,34 +79,54 @@ class HomeScreenContent extends StatelessWidget { safeAreaTop: MediaQuery.paddingOf(context).top, ); - return Column( - children: [ - ColoredBox( - color: colorScheme.primary, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: metrics.safeAreaGap), - SizedBox(height: metrics.heroTopPadding), - _CharacterSection( - score: score, - height: metrics.bannerHeight, - topPadding: 8, - ), - _TodaysScheduleOverlay(metrics: metrics), - ], + return CustomScrollView( + key: const Key('home-content-scroll'), + slivers: [ + SliverToBoxAdapter( + child: ColoredBox( + color: colorScheme.primary, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: metrics.safeAreaGap), + SizedBox(height: metrics.heroTopPadding), + _CharacterSection( + score: score, + height: metrics.bannerHeight, + topPadding: 8, + ), + _TodaysScheduleOverlay( + metrics: metrics, + now: now ?? DateTime.now(), + ), + ], + ), ), ), - Expanded( - child: Container( - padding: EdgeInsets.only( - left: metrics.sectionHorizontalPadding, - right: metrics.sectionHorizontalPadding, - ), - decoration: BoxDecoration(color: colorScheme.surface), - child: _MonthlySchedule( - monthlySchedulesState: state, - metrics: metrics, + SliverLayoutBuilder( + builder: (context, sliverConstraints) => SliverToBoxAdapter( + child: SizedBox( + // Keep all six calendar rows reachable when a retry notice + // or larger text expands the naturally sized appointment. + // This uses total preceding extent, not the changing scroll + // offset, so scrolling cannot resize the calendar itself. + height: math.max( + metrics.minimumMonthlyHeight, + sliverConstraints.viewportMainAxisExtent - + sliverConstraints.precedingScrollExtent, + ), + child: Container( + padding: EdgeInsets.only( + left: metrics.sectionHorizontalPadding, + right: metrics.sectionHorizontalPadding, + ), + decoration: BoxDecoration(color: colorScheme.surface), + child: _MonthlySchedule( + monthlySchedulesState: state, + metrics: metrics, + today: now, + ), + ), ), ), ), @@ -113,9 +139,10 @@ class HomeScreenContent extends StatelessWidget { } class _TodaysScheduleOverlay extends StatelessWidget { - const _TodaysScheduleOverlay({required this.metrics}); + const _TodaysScheduleOverlay({required this.metrics, required this.now}); final _HomeLayoutMetrics metrics; + final DateTime now; @override Widget build(BuildContext context) { @@ -124,14 +151,16 @@ class _TodaysScheduleOverlay extends StatelessWidget { return BlocBuilder( builder: (context, scheduleState) { - final todaySchedule = - scheduleState.status == ScheduleStatus.notExists || - scheduleState.status == ScheduleStatus.initial - ? null - : scheduleState.schedule; + final selection = HomeScheduleCardSelection.at(scheduleState, now); + final todaySchedule = selection.schedule; final hasSchedule = todaySchedule != null; + final l10n = AppLocalizations.of(context)!; final target = resolveTodayTileNavigationTarget( - scheduleStatus: scheduleState.status, + scheduleStatus: + selection.kind == HomeScheduleCardKind.today || + selection.kind == HomeScheduleCardKind.next + ? ScheduleStatus.upcoming + : scheduleState.status, hasSchedule: hasSchedule, ); @@ -178,7 +207,17 @@ class _TodaysScheduleOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(context)!.todaysAppointments, + switch (selection.kind) { + HomeScheduleCardKind.active => + l10n.homePreparationInProgress, + HomeScheduleCardKind.prompt => + l10n.homePreparationPrompt, + HomeScheduleCardKind.next => + l10n.homeNextAppointment, + HomeScheduleCardKind.stale => + l10n.homePreviouslyCheckedAppointment, + _ => l10n.todaysAppointments, + }, style: (metrics.compact ? theme.textTheme.titleSmall @@ -186,21 +225,31 @@ class _TodaysScheduleOverlay extends StatelessWidget { ?.copyWith( height: metrics.compact ? 22 / 16 : 22 / 18, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - SizedBox(height: metrics.todayTitleGap), - TodaysScheduleTile( - key: const Key('today_schedule_tile'), - schedule: todaySchedule, - compact: metrics.compact, - onTap: target == null - ? null - : () => context.go( - target.path, - extra: target.extra, - ), + if (selection.kind == HomeScheduleCardKind.today) + const DateBasisInfo(home: true), + NearestScheduleQueryStatus( + query: scheduleState.nearestQuery, + showStaleNotice: + selection.kind == HomeScheduleCardKind.stale, ), + SizedBox(height: metrics.todayTitleGap), + if (todaySchedule != null || + scheduleState.nearestQuery is NearestQueryEmpty) + TodaysScheduleTile( + key: const Key('today_schedule_tile'), + schedule: todaySchedule, + compact: metrics.compact, + resolution: selection.resolution, + emptyLabel: l10n.homeNoUpcomingAppointments, + onTap: + target == null || !selection.canOpenPreparation + ? null + : () => context.go( + target.path, + extra: target.extra, + ), + ), ], ), ), @@ -218,10 +267,12 @@ class _MonthlySchedule extends StatelessWidget { const _MonthlySchedule({ required this.monthlySchedulesState, required this.metrics, + this.today, }); final MonthlySchedulesState monthlySchedulesState; final _HomeLayoutMetrics metrics; + final DateTime? today; @override Widget build(BuildContext context) { @@ -236,6 +287,7 @@ class _MonthlySchedule extends StatelessWidget { child: MonthCalendar( key: const Key('home_month_calendar'), monthlySchedulesState: monthlySchedulesState, + today: today, rowHeight: metrics.calendarRowHeightFor(constraints.maxHeight), daysOfWeekHeight: metrics.calendarDaysOfWeekHeight, contentPadding: EdgeInsets.only( @@ -390,6 +442,15 @@ class _HomeLayoutMetrics { final double calendarPadding; final double calendarFabClearance; + double get minimumMonthlyHeight => + monthlyHeaderHeight + + 72 + + calendarDaysOfWeekHeight + + 6 * 28 + + calendarPadding * 2 + + calendarFabClearance + + sectionBottomPadding; + // Measure the space left after the naturally sized hero/card and header, // rather than subtracting a fixed card height from the whole viewport. double calendarRowHeightFor(double availableHeight) { diff --git a/lib/presentation/home/utils/home_schedule_card_selection.dart b/lib/presentation/home/utils/home_schedule_card_selection.dart new file mode 100644 index 00000000..671c9542 --- /dev/null +++ b/lib/presentation/home/utils/home_schedule_card_selection.dart @@ -0,0 +1,71 @@ +import 'package:on_time_front/core/time/device_civil_day.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +enum HomeScheduleCardKind { none, today, next, active, prompt, stale } + +final class HomeScheduleCardSelection { + const HomeScheduleCardSelection( + this.schedule, { + this.kind = HomeScheduleCardKind.none, + this.resolution, + }); + final ScheduleWithPreparationEntity? schedule; + final HomeScheduleCardKind kind; + final ScheduleTimeResolution? resolution; + bool get isActive => kind == HomeScheduleCardKind.active; + bool get canOpenPreparation => + schedule != null && + kind != HomeScheduleCardKind.stale && + kind != HomeScheduleCardKind.none; + + factory HomeScheduleCardSelection.at(ScheduleState state, DateTime now) { + final schedule = state.schedule; + if (schedule != null && + schedule.doneStatus == ScheduleDoneStatus.notEnded && + schedule.isStarted && + schedule.startedAt != null && + schedule.preparationFrozen) { + return HomeScheduleCardSelection( + schedule, + kind: HomeScheduleCardKind.active, + resolution: + schedule.timeResolution ?? + ScheduleTimeResolver.resolve(schedule, nowUtc: now), + ); + } + if (state.hasOwnedPreparationSurface) { + if (schedule != null && + schedule.doneStatus == ScheduleDoneStatus.notEnded) { + return HomeScheduleCardSelection( + schedule, + kind: HomeScheduleCardKind.prompt, + resolution: + schedule.timeResolution ?? + ScheduleTimeResolver.resolve(schedule, nowUtc: now), + ); + } + return const HomeScheduleCardSelection(null); + } + final fresh = state.freshNearest; + final value = fresh ?? state.staleNearest; + if (value == null || + value.schedule.doneStatus != ScheduleDoneStatus.notEnded || + value.schedule.retainedRecurringReference) { + return const HomeScheduleCardSelection(null); + } + final instant = value.resolution.instantUtc; + if (instant == null) return const HomeScheduleCardSelection(null); + return HomeScheduleCardSelection( + value.schedule, + resolution: value.resolution, + kind: fresh == null + ? HomeScheduleCardKind.stale + : DeviceCivilDay.at(now).contains(instant) + ? HomeScheduleCardKind.today + : HomeScheduleCardKind.next, + ); + } +} diff --git a/lib/presentation/my_page/cubit/detailed_notification_settings_cubit.dart b/lib/presentation/my_page/cubit/detailed_notification_settings_cubit.dart new file mode 100644 index 00000000..b4610d99 --- /dev/null +++ b/lib/presentation/my_page/cubit/detailed_notification_settings_cubit.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'detailed_notification_settings_state.dart'; +export 'detailed_notification_settings_state.dart'; + +/// DI owns this controller. A settings route only subscribes; leaving the route +/// does not discard a pending privacy choice or cancel a platform Future. +@lazySingleton +class DetailedNotificationSettingsCubit + extends Cubit { + DetailedNotificationSettingsCubit( + DetailedNotificationPreferenceService preferences, + ReconcileAlarmsUseCase reconcile, { + @ignoreParam AlarmOperationCoordinator? operations, + }) : this.test(preferences, reconcile, operations: operations); + + DetailedNotificationSettingsCubit.test( + this._preferences, + this._reconcile, { + AlarmOperationCoordinator? operations, + Duration delayNotice = const Duration(seconds: 10), + }) : _operations = operations ?? AlarmOperationCoordinator.shared, + _delayNotice = delayNotice, + super( + DetailedNotificationSettingsState( + generation: + (operations ?? AlarmOperationCoordinator.shared).generation, + ), + ) { + _operations.gate.addListener(_gateChanged); + } + + final DetailedNotificationPreferencePort _preferences; + final ReconcileAlarmsUseCase _reconcile; + final AlarmOperationCoordinator _operations; + final Duration _delayNotice; + int _revision = 0; + int _deliveryRevision = 0; + int _deliveryInFlight = 0; + bool _writing = false; + _Intent? _pending; + _Intent? _retryIntent; + Future? _refreshing; + Timer? _notice; + AlarmReconciliationResult? lastReconciliationResult; + + bool _current(int generation, int revision) => + !isClosed && + generation == _operations.generation && + revision == _revision && + _operations.canSchedule; + + void _gateChanged() { + if (isClosed) return; + if (state.generation != _operations.generation || + !_operations.canSchedule) { + _revision++; + _deliveryRevision++; + _notice?.cancel(); + _pending = null; + _retryIntent = null; + lastReconciliationResult = null; + emit( + DetailedNotificationSettingsState( + generation: _operations.generation, + load: DetailedPreferenceLoad.unavailable, + ), + ); + } + } + + void _update({ + bool? confirmed, + bool? requested, + bool clearConfirmed = false, + bool clearRequested = false, + bool? schedulesEnabled, + DetailedPreferenceLoad? load, + DetailedPreferenceSave? save, + DetailedPreferenceDelivery? delivery, + }) { + if (isClosed) return; + emit( + DetailedNotificationSettingsState( + generation: _operations.generation, + confirmedEnabled: clearConfirmed + ? null + : confirmed ?? state.confirmedEnabled, + requestedEnabled: clearRequested + ? null + : requested ?? state.requestedEnabled, + scheduleNotificationsEnabled: + schedulesEnabled ?? state.scheduleNotificationsEnabled, + load: load ?? state.load, + save: save ?? state.save, + delivery: delivery ?? state.delivery, + ), + ); + } + + Future refresh() { + if (_writing || _pending != null) return Future.value(); + return _refreshing ??= _refresh().whenComplete(() => _refreshing = null); + } + + Future _refresh() async { + final generation = _operations.generation; + final revision = _revision; + if (!_operations.canSchedule) { + _gateChanged(); + return; + } + if (state.confirmedEnabled == null) { + _update(load: DetailedPreferenceLoad.loading); + } + try { + final snapshot = await _preferences.read(expectedGeneration: generation); + if (!_current(generation, revision)) return; + _update( + confirmed: snapshot.detailedEnabled, + schedulesEnabled: snapshot.scheduleNotificationsEnabled, + load: DetailedPreferenceLoad.ready, + ); + if (_deliveryInFlight == 0) { + await _apply(snapshot, revision); + } + } catch (_) { + if (!_current(generation, revision)) return; + _update( + clearConfirmed: true, + load: DetailedPreferenceLoad.failed, + delivery: DetailedPreferenceDelivery.unknown, + ); + } + } + + /// Accept synchronously; persistence and delivery report through state. + void request(bool enabled) { + if (!state.canRequest || !_operations.canSchedule || isClosed) return; + if (state.requestedEnabled == enabled || + (state.requestedEnabled == null && + state.confirmedEnabled == enabled && + state.save == DetailedPreferenceSave.idle)) { + return; + } + final generation = _operations.generation; + final permit = _operations.acceptContentIntent( + enabled, + expectedGeneration: generation, + ); + final intent = _Intent(enabled, ++_revision, permit); + _pending = intent; + _retryIntent = null; + _notice?.cancel(); + _deliveryRevision++; + _update( + requested: enabled, + save: DetailedPreferenceSave.saving, + delivery: DetailedPreferenceDelivery.unknown, + ); + unawaited(_drain()); + } + + Future _drain() async { + if (_writing) return; + _writing = true; + try { + while (_pending != null && !isClosed) { + final intent = _pending!; + _pending = null; + if (!_current(intent.permit.generation, intent.revision)) continue; + try { + final snapshot = await _preferences.write( + intent.enabled, + expectedGeneration: intent.permit.generation, + ); + if (!_current(intent.permit.generation, intent.revision)) continue; + _acceptedSnapshot(intent, snapshot); + } catch (_) { + if (!_current(intent.permit.generation, intent.revision)) continue; + try { + final snapshot = await _preferences.read( + expectedGeneration: intent.permit.generation, + ); + if (!_current(intent.permit.generation, intent.revision)) continue; + _operations.confirmContentIntent( + intent.permit, + committedEnabled: snapshot.detailedEnabled, + ); + if (snapshot.detailedEnabled == intent.enabled) { + _acceptedSnapshot(intent, snapshot); + } else { + _retryIntent = intent; + _update( + confirmed: snapshot.detailedEnabled, + schedulesEnabled: snapshot.scheduleNotificationsEnabled, + load: DetailedPreferenceLoad.ready, + save: DetailedPreferenceSave.failed, + delivery: intent.enabled + ? DetailedPreferenceDelivery.needsCheck + : DetailedPreferenceDelivery.contentDeferred, + ); + } + } catch (_) { + if (!_current(intent.permit.generation, intent.revision)) continue; + _retryIntent = intent; + _update( + clearConfirmed: true, + load: DetailedPreferenceLoad.failed, + save: DetailedPreferenceSave.failed, + delivery: DetailedPreferenceDelivery.unknown, + ); + } + } + } + } finally { + _writing = false; + } + } + + void _acceptedSnapshot( + _Intent intent, + DetailedNotificationPreferenceSnapshot snapshot, + ) { + _operations.confirmContentIntent( + intent.permit, + committedEnabled: snapshot.detailedEnabled, + ); + _retryIntent = null; + _update( + confirmed: snapshot.detailedEnabled, + clearRequested: true, + schedulesEnabled: snapshot.scheduleNotificationsEnabled, + load: DetailedPreferenceLoad.ready, + save: DetailedPreferenceSave.idle, + ); + // Never block the DB drain on a platform Future. A later OFF may commit + // while the existing OS owner is still waiting for a native response. + unawaited(_apply(snapshot, intent.revision)); + } + + Future retry() async { + if (isClosed || _writing || !_operations.canSchedule) return; + final intent = _retryIntent; + if (intent != null && _current(intent.permit.generation, intent.revision)) { + _pending = intent; + _retryIntent = null; + _update(save: DetailedPreferenceSave.saving); + await _drain(); + } else if (_deliveryInFlight == 0) { + await refresh(); + } + } + + Future _apply( + DetailedNotificationPreferenceSnapshot snapshot, + int revision, + ) async { + if (!_current(snapshot.generation, revision)) return; + final delivery = ++_deliveryRevision; + _deliveryInFlight++; + _notice?.cancel(); + _update(delivery: DetailedPreferenceDelivery.applying); + _notice = Timer(_delayNotice, () { + if (_current(snapshot.generation, revision) && + delivery == _deliveryRevision) { + _update(delivery: DetailedPreferenceDelivery.delayed); + } + }); + try { + final result = await _reconcile(); + if (!_current(snapshot.generation, revision) || + delivery != _deliveryRevision) { + return; + } + lastReconciliationResult = result; + _update(delivery: _deliveryState(snapshot, result)); + } catch (_) { + if (_current(snapshot.generation, revision) && + delivery == _deliveryRevision) { + _update(delivery: DetailedPreferenceDelivery.needsCheck); + } + } finally { + _deliveryInFlight--; + if (delivery == _deliveryRevision) _notice?.cancel(); + } + } + + DetailedPreferenceDelivery _deliveryState( + DetailedNotificationPreferenceSnapshot snapshot, + AlarmReconciliationResult result, + ) { + final reasons = result.failures.map((failure) => failure.reason).toSet(); + if (reasons.contains(AlarmFailureReason.cancellationFailed)) { + return DetailedPreferenceDelivery.cancellationUnconfirmed; + } + if (reasons.contains(AlarmFailureReason.observationFailed) || + result.status == AlarmReconciliationStatus.settingsUnavailable) { + return DetailedPreferenceDelivery.needsCheck; + } + if (reasons.contains(AlarmFailureReason.contentDeferred)) { + return DetailedPreferenceDelivery.contentDeferred; + } + if (result.status == AlarmReconciliationStatus.partial) { + return reasons.contains(AlarmFailureReason.platformError) + ? DetailedPreferenceDelivery.schedulingFailed + : DetailedPreferenceDelivery.needsCheck; + } + if (result.status == AlarmReconciliationStatus.disabled && + !snapshot.scheduleNotificationsEnabled) { + return DetailedPreferenceDelivery.off; + } + if (result.status == AlarmReconciliationStatus.permissionNeeded) { + return DetailedPreferenceDelivery.permissionNeeded; + } + if (result.status != AlarmReconciliationStatus.armed) { + return DetailedPreferenceDelivery.needsCheck; + } + return result.armedScheduleIds.isEmpty + ? DetailedPreferenceDelivery.noUpcoming + : DetailedPreferenceDelivery.applied; + } + + @override + @disposeMethod + Future close() { + _operations.gate.removeListener(_gateChanged); + _notice?.cancel(); + _pending = null; + return super.close(); + } +} + +final class _Intent { + const _Intent(this.enabled, this.revision, this.permit); + final bool enabled; + final int revision; + final AlarmContentPermit permit; +} diff --git a/lib/presentation/my_page/cubit/detailed_notification_settings_state.dart b/lib/presentation/my_page/cubit/detailed_notification_settings_state.dart new file mode 100644 index 00000000..fadc3494 --- /dev/null +++ b/lib/presentation/my_page/cubit/detailed_notification_settings_state.dart @@ -0,0 +1,44 @@ +enum DetailedPreferenceLoad { loading, ready, failed, unavailable } + +enum DetailedPreferenceSave { idle, saving, failed } + +enum DetailedPreferenceDelivery { + unknown, + applying, + delayed, + applied, + off, + noUpcoming, + permissionNeeded, + cancellationUnconfirmed, + schedulingFailed, + needsCheck, + contentDeferred, +} + +final class DetailedNotificationSettingsState { + const DetailedNotificationSettingsState({ + required this.generation, + this.confirmedEnabled, + this.requestedEnabled, + this.scheduleNotificationsEnabled, + this.load = DetailedPreferenceLoad.loading, + this.save = DetailedPreferenceSave.idle, + this.delivery = DetailedPreferenceDelivery.unknown, + }); + final int generation; + final bool? confirmedEnabled; + final bool? requestedEnabled; + final bool? scheduleNotificationsEnabled; + final DetailedPreferenceLoad load; + final DetailedPreferenceSave save; + final DetailedPreferenceDelivery delivery; + + bool? get displayedEnabled => requestedEnabled ?? confirmedEnabled; + bool get canRequest => + load == DetailedPreferenceLoad.ready && confirmedEnabled != null; + bool get canRetry => + save != DetailedPreferenceSave.saving && + delivery != DetailedPreferenceDelivery.applying && + delivery != DetailedPreferenceDelivery.delayed; +} diff --git a/lib/presentation/my_page/my_data_screen.dart b/lib/presentation/my_page/my_data_screen.dart index 43c62430..82be6938 100644 --- a/lib/presentation/my_page/my_data_screen.dart +++ b/lib/presentation/my_page/my_data_screen.dart @@ -1,3 +1,8 @@ +import 'package:on_time_front/presentation/backup/backup_recurring_time_review_screen.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/presentation/backup/backup_time_review_screen.dart'; +import 'package:on_time_front/presentation/backup/backup_processing_panel.dart'; import 'package:on_time_front/domain/entities/local_reset_result.dart'; import 'package:on_time_front/domain/entities/backup_operation.dart'; import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; @@ -142,28 +147,40 @@ class _MyDataScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _restoreReceipt!.committed - ? l10n.dataRestorePartial - : l10n.dataUncommittedCleanup, - ), + Text(switch (_restoreReceipt!.disposition) { + BackupCommitDisposition.committed => + l10n.dataRestoreCleanupPending, + BackupCommitDisposition.notCommitted => + l10n.dataUncommittedCleanup, + BackupCommitDisposition.undetermined => + _unknownRestoreMessage, + }), TextButton( - onPressed: _busy ? null : _retryRestoreDelivery, - child: Text(l10n.dataRetryDelivery), + onPressed: _busy || _unknownCommit + ? null + : _retryRestoreDelivery, + child: Text(switch (_restoreReceipt!.disposition) { + BackupCommitDisposition.committed => + l10n.dataRetryCleanup, + BackupCommitDisposition.notCommitted => + l10n.dataRetryDelivery, + BackupCommitDisposition.undetermined => + l10n.dataChecking, + }), ), ], ), ), ), ListTile( - enabled: !_busy, + enabled: !_busy && !_unknownCommit, leading: const Icon(Icons.lock_outline), title: Text(l10n.dataExport), subtitle: Text(l10n.dataExportDescription), onTap: _export, ), ListTile( - enabled: !_busy, + enabled: !_busy && !_unknownCommit, leading: const Icon(Icons.restore), title: Text(l10n.dataRestore), subtitle: Text(l10n.dataRestoreDescription), @@ -171,7 +188,7 @@ class _MyDataScreenState extends State { ), const Divider(height: 32), ListTile( - enabled: !_busy, + enabled: !_busy && !_unknownCommit, leading: Icon( Icons.delete_forever, color: Theme.of(context).colorScheme.error, @@ -183,6 +200,7 @@ class _MyDataScreenState extends State { subtitle: Text(l10n.dataResetDescription), onTap: _resetData, ), + const BackupProcessingPanel(), if (_working) const Padding( padding: EdgeInsets.only(top: 24), @@ -193,6 +211,13 @@ class _MyDataScreenState extends State { ); } + bool get _unknownCommit => + _restoreReceipt?.disposition == BackupCommitDisposition.undetermined; + String get _unknownRestoreMessage => + Localizations.localeOf(context).languageCode == 'ko' + ? '복원 적용 여부를 아직 확인할 수 없습니다. 앱을 다시 열어 같은 작업을 확인하세요.' + : 'The restore outcome is not yet confirmed. Reopen the app to check the same operation.'; + String _freshnessLabel(BackupFreshnessStatus? status) { if (status == null) return l10n.dataChecking; return switch (status.freshness) { @@ -203,7 +228,7 @@ class _MyDataScreenState extends State { } Future _export() async { - if (_busy) return; + if (_busy || _unknownCommit) return; setState(() => _busy = true); final operationGeneration = _workflow.generation; try { @@ -242,9 +267,13 @@ class _MyDataScreenState extends State { } Future _restore() async { - if (_busy) return; + if (_busy || _unknownCommit) return; setState(() => _busy = true); final operationGeneration = _workflow.generation; + BackupRestoreInput? candidate; + BackupRestoreSelection? selected; + final workflow = _workflow; + BackupRestoreReceipt? ownedReceipt; try { final password = await _askPassword(confirm: false); if (password == null || @@ -254,13 +283,52 @@ class _MyDataScreenState extends State { } final generation = _workflow.generation; setState(() => _working = true); - final candidate = await _workflow.preview(password); + selected = await workflow.selectForRestore(password); if (!_routeCurrent || - generation != _workflow.generation || - candidate == null) { + !identical(workflow, _workflow) || + generation != workflow.generation || + selected == null) { return; } setState(() => _working = false); + if (!mounted) return; + if (selected is BackupTimeReviewInput) { + final review = selected; + candidate = await Navigator.of(context).push( + MaterialPageRoute( + builder: (reviewContext) => BackupTimeReviewScreen( + input: review, + onReviewRecurrence: (currentInput, issue) async { + if (!mounted || + !reviewContext.mounted || + ModalRoute.of(reviewContext)?.isCurrent == false || + !identical(workflow, _workflow) || + generation != workflow.generation) { + return; + } + await Navigator.of(reviewContext).push( + MaterialPageRoute( + builder: (_) => BackupRecurringTimeReviewScreen( + input: currentInput, + issue: issue, + ), + ), + ); + }, + ), + ), + ); + } else if (selected is BackupRestoreInput) { + candidate = selected; + } else { + throw const DataOperationException(DataOperationFailure.invalidBackup); + } + if (candidate == null || + !_routeCurrent || + !identical(workflow, _workflow) || + generation != workflow.generation) { + return; + } final preview = candidate.preview; if (!mounted) return; final confirmed = await showDialog( @@ -268,15 +336,22 @@ class _MyDataScreenState extends State { builder: (context) => AlertDialog( title: Text(l10n.dataRestorePreviewTitle), content: SingleChildScrollView( - child: Text( - l10n.dataRestorePreview( - '${preview.cutoff.toLocal()}', - preview.sourceAppVersion, - preview.sourcePlatform, - preview.scheduleCount, - preview.templateCount, - preview.defaultPreparationStepCount, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.dataRestorePreview( + preview.cutoffLiteral ?? '${preview.cutoff.toLocal()}', + preview.sourceAppVersion, + preview.sourcePlatform, + preview.scheduleCount, + preview.templateCount, + preview.defaultPreparationStepCount, + ), + ), + BackupPreviewNotices(input: candidate!), + ], ), ), actions: [ @@ -291,30 +366,71 @@ class _MyDataScreenState extends State { ], ), ); - if (confirmed != true || !_routeCurrent) return; + if (confirmed != true || + !_routeCurrent || + !identical(workflow, _workflow) || + generation != workflow.generation) { + return; + } setState(() => _working = true); - final receipt = await _workflow.restore(candidate); + final receipt = await workflow.restore(candidate); + ownedReceipt = receipt; if (!_routeCurrent || receipt.generation != _workflow.generation) return; setState(() => _restoreReceipt = receipt); - if (receipt.committed) { - _message( - receipt.followUpPending - ? l10n.dataRestorePartial - : l10n.dataRestoreComplete, - ); - await _loadFreshness(); - } else { - _showError( - DataOperationException( - receipt.failure ?? DataOperationFailure.failed, - ), - ); + switch (receipt.disposition) { + case BackupCommitDisposition.committed: + _message( + receipt.followUpPending + ? l10n.dataRestoreCleanupPending + : l10n.dataRestoreComplete, + ); + await _loadFreshness(); + case BackupCommitDisposition.undetermined: + _message(_unknownRestoreMessage); + case BackupCommitDisposition.notCommitted: + _showError( + DataOperationException( + receipt.failure ?? DataOperationFailure.failed, + ), + ); } } catch (error) { - if (_routeCurrent && operationGeneration == _workflow.generation) { - _showError(error); + if (_routeCurrent && identical(workflow, _workflow)) { + if (error is DataOperationException && + error.followUpPending && + error.generation == workflow.generation) { + setState( + () => _restoreReceipt = BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: error.generation!, + followUpPending: true, + failure: error.failure, + ), + ); + _showError(error); + } else if (operationGeneration == workflow.generation) { + _showError(error); + } } } finally { + try { + if (ownedReceipt?.disposition != BackupCommitDisposition.undetermined) { + try { + await candidate?.dispose(); + } finally { + if (!identical(candidate, selected)) await selected?.dispose(); + } + } + } catch (error) { + final ownerGeneration = ownedReceipt?.generation ?? operationGeneration; + if (_routeCurrent && + ownerGeneration == _workflow.generation && + (ownedReceipt == null || + ownedReceipt.disposition == + BackupCommitDisposition.notCommitted)) { + _showError(error); + } + } if (mounted) { setState(() { _busy = false; @@ -344,7 +460,7 @@ class _MyDataScreenState extends State { } Future _resetData() async { - if (_busy) return; + if (_busy || _unknownCommit) return; setState(() => _busy = true); final generation = _workflow.generation; try { @@ -387,6 +503,18 @@ class _MyDataScreenState extends State { } void _showError(Object error) { + final processing = backupProcessingError( + error, + Localizations.localeOf(context).languageCode == 'ko', + ); + if (processing != null) { + _message(processing); + return; + } + if (error is RestoreStagingCleanupFailure) { + _message(l10n.dataStagingCleanupFailed); + return; + } final code = error is DataOperationException ? error.failure : DataOperationFailure.failed; diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 71a734eb..26974f94 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:on_time_front/presentation/my_page/cubit/detailed_notification_settings_cubit.dart'; import 'package:on_time_front/domain/entities/schedule_notification_status.dart'; import 'package:on_time_front/presentation/shared/components/notification_timing_education.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; @@ -10,7 +12,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; -import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; @@ -269,62 +270,140 @@ class _DetailedNotificationTile extends StatefulWidget { _DetailedNotificationTileState(); } -class _DetailedNotificationTileState extends State<_DetailedNotificationTile> { - bool _enabled = false; - bool _loading = true; +class _DetailedNotificationTileState extends State<_DetailedNotificationTile> + with WidgetsBindingObserver { + late final _controller = getIt(); @override void initState() { super.initState(); - _load(); + WidgetsBinding.instance.addObserver(this); + unawaited(_controller.refresh()); } - Future _load() async { - final enabled = await getIt() - .getEnabled(); - if (mounted) { - setState(() { - _enabled = enabled; - _loading = false; - }); - } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(_controller.refresh()); } - Future _change(bool enabled) async { - setState(() => _enabled = enabled); - await getIt().setEnabled(enabled); - var incomplete = false; - try { - final result = await getIt()(); - incomplete = - result.status == AlarmReconciliationStatus.partial || - result.status == AlarmReconciliationStatus.settingsUnavailable; - } catch (_) { - incomplete = true; - } - if (incomplete && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.notificationIncompleteStatus, - ), - ), - ); - } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + // The installation, not this route, owns accepted privacy choices. + super.dispose(); } @override - Widget build(BuildContext context) { - return SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('알림에 일정 이름 표시'), - secondary: const Icon(Icons.person_outline, size: 24), - dense: true, - activeThumbColor: Theme.of(context).colorScheme.primary, - value: _enabled, - onChanged: _loading ? null : _change, - ); - } + Widget build(BuildContext context) => + BlocBuilder< + DetailedNotificationSettingsCubit, + DetailedNotificationSettingsState + >( + bloc: _controller, + builder: (context, state) { + final l10n = AppLocalizations.of(context)!; + final lines = []; + if (state.load != DetailedPreferenceLoad.ready) { + lines.add(switch (state.load) { + DetailedPreferenceLoad.loading => + l10n.detailedNotificationLoading, + DetailedPreferenceLoad.failed => + l10n.detailedNotificationReadFailed, + DetailedPreferenceLoad.unavailable => + l10n.detailedNotificationUnavailable, + DetailedPreferenceLoad.ready => '', + }); + } + if (state.confirmedEnabled != null) { + lines.add( + state.confirmedEnabled! + ? l10n.detailedNotificationSavedOn + : l10n.detailedNotificationSavedOff, + ); + } + if (state.requestedEnabled != null) { + lines.add( + state.requestedEnabled! + ? l10n.detailedNotificationRequestOn + : l10n.detailedNotificationRequestOff, + ); + } + if (state.save == DetailedPreferenceSave.failed) { + lines.add(l10n.detailedNotificationSaveFailed); + } + final delivery = switch (state.delivery) { + DetailedPreferenceDelivery.unknown => '', + DetailedPreferenceDelivery.applying => + l10n.detailedNotificationApplying, + DetailedPreferenceDelivery.delayed => + l10n.detailedNotificationDelayed, + DetailedPreferenceDelivery.applied => + l10n.detailedNotificationApplied, + DetailedPreferenceDelivery.off => l10n.detailedNotificationOff, + DetailedPreferenceDelivery.noUpcoming => + l10n.detailedNotificationEmpty, + DetailedPreferenceDelivery.permissionNeeded => + l10n.detailedNotificationPermission, + DetailedPreferenceDelivery.cancellationUnconfirmed => + l10n.detailedNotificationCancellation, + DetailedPreferenceDelivery.schedulingFailed => + l10n.detailedNotificationSchedulingFailed, + DetailedPreferenceDelivery.needsCheck => + l10n.detailedNotificationNeedsCheck, + DetailedPreferenceDelivery.contentDeferred => + l10n.detailedNotificationHeld, + }; + if (delivery.isNotEmpty) lines.add(delivery); + final status = lines.join('\n'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state.displayedEnabled == null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline, size: 24), + title: Text(l10n.detailedNotificationTitle), + subtitle: Text(l10n.detailedNotificationPrivacy), + ) + else + SwitchListTile( + key: const Key('detailedNotificationSwitch'), + contentPadding: EdgeInsets.zero, + title: Text(l10n.detailedNotificationTitle), + subtitle: Text(l10n.detailedNotificationPrivacy), + secondary: const Icon(Icons.person_outline, size: 24), + dense: true, + activeThumbColor: Theme.of(context).colorScheme.primary, + value: state.displayedEnabled!, + onChanged: state.canRequest ? _controller.request : null, + ), + Semantics( + liveRegion: true, + child: Text( + status, + key: const Key('detailedNotificationStatus'), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (state.load == DetailedPreferenceLoad.failed || + state.save == DetailedPreferenceSave.failed || + const { + DetailedPreferenceDelivery.cancellationUnconfirmed, + DetailedPreferenceDelivery.schedulingFailed, + DetailedPreferenceDelivery.needsCheck, + DetailedPreferenceDelivery.contentDeferred, + }.contains(state.delivery)) + TextButton( + key: const Key('detailedNotificationRetry'), + onPressed: state.canRetry + ? () => unawaited(_controller.retry()) + : null, + child: Text(l10n.detailedNotificationRetry), + ), + ], + ); + }, + ); } class _AlarmStatusView extends StatefulWidget { @@ -342,16 +421,23 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> String _statusLabel = '확인 중'; AlarmPermissionState _timingPermission = AlarmPermissionState.unsupported; Future? _loading; + late final _details = getIt(); + StreamSubscription? _detailSubscription; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _detailSubscription = _details.stream.listen((_) { + if (!mounted) return; + unawaited(_load(reconcile: false)); + }); _load(); } @override void dispose() { + _detailSubscription?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -361,10 +447,10 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> if (state == AppLifecycleState.resumed && !_isUpdating) unawaited(_load()); } - Future _load() => - _loading ??= _loadStatus().whenComplete(() => _loading = null); + Future _load({bool reconcile = true}) => + _loading ??= _loadStatus(reconcile).whenComplete(() => _loading = null); - Future _loadStatus() async { + Future _loadStatus(bool reconcile) async { setState(() { _isLoading = true; }); @@ -374,8 +460,8 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> final schedulerService = getIt.get(); final fallbackService = getIt.get(); + if (reconcile) await _details.refresh(); final settings = await alarmRepository.getAlarmSettings(); - final result = await getIt()(); final records = await registryRepository.loadAll(); final timingPermission = await fallbackService .checkExactTimingPermission(); @@ -394,7 +480,7 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> settings: settings, records: records, delivery: delivery.policy, - result: result, + result: _details.lastReconciliationResult, ); _isLoading = false; }); @@ -413,29 +499,49 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> required List records, required AlarmDeliveryPolicy delivery, required AlarmReconciliationResult? result, - }) => switch (scheduleNotificationStatus( - enabled: settings.alarmsEnabled, - canDeliver: delivery.canDeliver, - records: records, - result: result, - now: DateTime.now(), - requiresExactTimingEvidence: - _timingPermission != AlarmPermissionState.unsupported, - )) { - ScheduleNotificationStatus.off => '꺼짐', - ScheduleNotificationStatus.cleanupNeeded => - l10n.notificationCleanupNeededStatus, - ScheduleNotificationStatus.permissionNeeded => - l10n.notificationPermissionNeededStatus, - ScheduleNotificationStatus.empty => l10n.noScheduledNotificationStatus, - ScheduleNotificationStatus.alarm => l10n.alarmStatus, - ScheduleNotificationStatus.notification => l10n.notificationStatus, - ScheduleNotificationStatus.precise => l10n.preciseNotificationStatus, - ScheduleNotificationStatus.approximate => - l10n.notificationApproximateStatus, - ScheduleNotificationStatus.mixed => l10n.notificationMixedTimingStatus, - ScheduleNotificationStatus.incomplete => l10n.notificationIncompleteStatus, - }; + }) { + var status = scheduleNotificationStatus( + enabled: settings.alarmsEnabled, + canDeliver: delivery.canDeliver, + records: records, + result: result, + now: DateTime.now(), + requiresExactTimingEvidence: + _timingPermission != AlarmPermissionState.unsupported, + ); + // Current OFF, cleanup and permission evidence remains meaningful even + // while a content preference is unresolved. Only delivery success claims + // depend on the latest content request having finished. + if (status != ScheduleNotificationStatus.off && + status != ScheduleNotificationStatus.cleanupNeeded && + status != ScheduleNotificationStatus.permissionNeeded && + (_details.state.save != DetailedPreferenceSave.idle || + const { + DetailedPreferenceDelivery.unknown, + DetailedPreferenceDelivery.applying, + DetailedPreferenceDelivery.delayed, + DetailedPreferenceDelivery.needsCheck, + DetailedPreferenceDelivery.contentDeferred, + }.contains(_details.state.delivery))) { + status = ScheduleNotificationStatus.incomplete; + } + return switch (status) { + ScheduleNotificationStatus.off => '꺼짐', + ScheduleNotificationStatus.cleanupNeeded => + l10n.notificationCleanupNeededStatus, + ScheduleNotificationStatus.permissionNeeded => + l10n.notificationPermissionNeededStatus, + ScheduleNotificationStatus.empty => l10n.noScheduledNotificationStatus, + ScheduleNotificationStatus.alarm => l10n.alarmStatus, + ScheduleNotificationStatus.notification => l10n.notificationStatus, + ScheduleNotificationStatus.precise => l10n.preciseNotificationStatus, + ScheduleNotificationStatus.approximate => + l10n.notificationApproximateStatus, + ScheduleNotificationStatus.mixed => l10n.notificationMixedTimingStatus, + ScheduleNotificationStatus.incomplete => + l10n.notificationIncompleteStatus, + }; + } Future _openTimingSettings() async { if (_isUpdating || _isLoading) return; diff --git a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart index 1693bf5a..aab16c7d 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart @@ -1,11 +1,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_default_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_spare_time_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/default_preferences_workflow.dart'; part 'default_preparation_spare_time_form_event.dart'; part 'default_preparation_spare_time_form_state.dart'; @@ -17,110 +16,155 @@ class DefaultPreparationSpareTimeFormBloc DefaultPreparationSpareTimeFormEvent, DefaultPreparationSpareTimeFormState > { - DefaultPreparationSpareTimeFormBloc( - this._getDefaultPreparationUseCase, - this._updateDefaultPreparationUseCase, - this._updateSpareTimeUseCase, - this._loadUserUseCase, - ) : super(DefaultPreparationSpareTimeFormState()) { - on(_onFormEditRequested); - on(_onSpareTimeIncreased); - on(_onSpareTimeDecreased); - on(_onFormSubmitted); + DefaultPreparationSpareTimeFormBloc(this._workflow) + : super(const DefaultPreparationSpareTimeFormState()) { + on(_load); + on((event, emit) => _changeSpare(5, emit)); + on((event, emit) => _changeSpare(-5, emit)); + on(_save); + on(_retry); } + final DefaultPreferencesWorkflow _workflow; + Object _owner = Object(); + bool _busy = false; + Object get formOwner => _owner; + bool ownsForm(Object owner) => !isClosed && identical(owner, _owner); + bool get current => + state.baseline != null && + _workflow.isGenerationCurrent(state.baseline!.generation); - final GetDefaultPreparationUseCase _getDefaultPreparationUseCase; - final UpdateDefaultPreparationUseCase _updateDefaultPreparationUseCase; - final UpdateSpareTimeUseCase _updateSpareTimeUseCase; - final LoadUserUseCase _loadUserUseCase; - final Duration lowerBound = Duration(minutes: 10); - final Duration stepSize = Duration(minutes: 5); - - Future _onFormEditRequested( + Future _load( FormEditRequested event, Emitter emit, ) async { - emit( - state.copyWith( - status: DefaultPreparationSpareTimeStatus.loading, - errorMessage: null, - ), - ); - - final preparation = await _getDefaultPreparationUseCase(); - - emit( - state.copyWith( - status: DefaultPreparationSpareTimeStatus.success, - preparation: preparation, - spareTime: event.spareTime, - errorMessage: null, - ), - ); + if (_busy) return; + final owner = _owner = Object(); + _busy = true; + emit(state.copyWith(status: DefaultPreparationSpareTimeStatus.loading)); + try { + final snapshot = await _workflow.read(); + if (!ownsForm(owner) || + !_workflow.isGenerationCurrent(snapshot.generation)) { + return; + } + emit( + DefaultPreparationSpareTimeFormState( + status: DefaultPreparationSpareTimeStatus.success, + preparation: snapshot.preparation, + spareTime: snapshot.spareTime, + baseline: snapshot, + editorVersion: state.editorVersion + 1, + ), + ); + } catch (_) { + if (ownsForm(owner)) { + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.error, + failure: state.failure ?? DefaultPreferencesFailure.failed, + ), + ); + } + } finally { + if (ownsForm(owner)) _busy = false; + } } - void _onSpareTimeIncreased( - SpareTimeIncreased event, + void _changeSpare( + int delta, Emitter emit, ) { - final currentSpareTime = state.spareTime ?? Duration.zero; - final newSpareTime = currentSpareTime + stepSize; - - emit(state.copyWith(spareTime: newSpareTime)); + if (_busy || !state.canEdit) return; + final minutes = state.spareTime!.inMinutes + delta; + if (minutes < 0 || minutes > LocalInputLimits.maxMinuteValue) return; + emit(state.copyWith(spareTime: Duration(minutes: minutes))); } - void _onSpareTimeDecreased( - SpareTimeDecreased event, + Future _save( + FormSubmitted event, Emitter emit, - ) { - final currentSpareTime = state.spareTime ?? Duration.zero; - final newSpareTime = currentSpareTime - stepSize; - - if (newSpareTime >= lowerBound) { - emit(state.copyWith(spareTime: newSpareTime)); + ) async { + if (_busy || !state.canSubmit) return; + final owner = _owner; + _busy = true; + final input = DefaultPreferencesSubmission( + baseline: state.baseline!, + preparation: event.preparation, + spareTime: state.spareTime!, + ); + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.submitting, + preparation: input.preparation, + clearFailure: true, + ), + ); + try { + final receipt = await _workflow.save(input); + if (!ownsForm(owner)) return; + _accept(receipt, emit); + } on DefaultPreferencesRejected catch (error) { + if (ownsForm(owner)) { + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.error, + failure: error.failure, + ), + ); + } + } catch (_) { + if (ownsForm(owner)) { + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.error, + failure: DefaultPreferencesFailure.failed, + ), + ); + } + } finally { + if (ownsForm(owner)) _busy = false; } } - Future _onFormSubmitted( - FormSubmitted event, + Future _retry( + FormFollowUpRetried event, Emitter emit, ) async { - if (state.spareTime == null) { - emit( - state.copyWith( - status: DefaultPreparationSpareTimeStatus.error, - errorMessage: null, - ), - ); - return; + final receipt = state.receipt; + if (_busy || receipt == null) return; + final owner = _owner; + _busy = true; + emit(state.copyWith(status: DefaultPreparationSpareTimeStatus.submitting)); + try { + final result = await _workflow.retry(receipt); + if (ownsForm(owner)) _accept(result, emit); + } finally { + if (ownsForm(owner)) _busy = false; } + } + void _accept( + DefaultPreferencesSaveReceipt receipt, + Emitter emit, + ) { + final replaced = + receipt.superseded || receipt.generation != _workflow.generation; + final current = _workflow.isGenerationCurrent(receipt.generation); + final result = receipt.withFollowUp( + reloadPending: receipt.reloadPending, + deliveryPending: receipt.deliveryPending, + superseded: replaced, + authorityPending: !replaced && (receipt.authorityPending || !current), + ); emit( state.copyWith( - status: DefaultPreparationSpareTimeStatus.submitting, - errorMessage: null, + status: result.complete + ? DefaultPreparationSpareTimeStatus.submitted + : DefaultPreparationSpareTimeStatus.followUpPending, + receipt: result, + failure: replaced ? DefaultPreferencesFailure.unavailable : null, + clearFailure: !replaced, ), ); - - try { - await _updateDefaultPreparationUseCase(event.preparation); - await _updateSpareTimeUseCase(state.spareTime!); - await _loadUserUseCase(); - - emit( - state.copyWith( - status: DefaultPreparationSpareTimeStatus.submitted, - preparation: event.preparation, - errorMessage: null, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: DefaultPreparationSpareTimeStatus.error, - errorMessage: e.toString(), - ), - ); - } } } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_event.dart b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_event.dart index 949b3fac..1e706c81 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_event.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_event.dart @@ -1,39 +1,31 @@ part of 'default_preparation_spare_time_form_bloc.dart'; -class DefaultPreparationSpareTimeFormEvent extends Equatable { +sealed class DefaultPreparationSpareTimeFormEvent extends Equatable { const DefaultPreparationSpareTimeFormEvent(); - @override List get props => []; } +/// Requested initially or after explicitly discarding the old draft. class FormEditRequested extends DefaultPreparationSpareTimeFormEvent { - const FormEditRequested({required this.spareTime}); - final Duration spareTime; - - @override - List get props => [spareTime]; + const FormEditRequested(); } class SpareTimeIncreased extends DefaultPreparationSpareTimeFormEvent { const SpareTimeIncreased(); - - @override - List get props => []; } class SpareTimeDecreased extends DefaultPreparationSpareTimeFormEvent { const SpareTimeDecreased(); - - @override - List get props => []; } class FormSubmitted extends DefaultPreparationSpareTimeFormEvent { - const FormSubmitted({required this.note, required this.preparation}); - final String note; + const FormSubmitted({required this.preparation}); final PreparationEntity preparation; - @override - List get props => [note]; + List get props => [preparation]; +} + +class FormFollowUpRetried extends DefaultPreparationSpareTimeFormEvent { + const FormFollowUpRetried(); } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart index 3337cf39..61fc1634 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart @@ -6,6 +6,7 @@ enum DefaultPreparationSpareTimeStatus { success, submitting, submitted, + followUpPending, error, } @@ -14,27 +15,54 @@ class DefaultPreparationSpareTimeFormState extends Equatable { this.status = DefaultPreparationSpareTimeStatus.initial, this.spareTime, this.preparation, - this.errorMessage, + this.baseline, + this.failure, + this.receipt, + this.editorVersion = 0, }); final DefaultPreparationSpareTimeStatus status; final Duration? spareTime; final PreparationEntity? preparation; - final String? errorMessage; + final DefaultPreferencesSnapshot? baseline; + final DefaultPreferencesFailure? failure; + final DefaultPreferencesSaveReceipt? receipt; + final int editorVersion; + bool get hasEditableData => spareTime != null && preparation != null; + bool get canEdit => + hasEditableData && + receipt == null && + (status == DefaultPreparationSpareTimeStatus.success || + status == DefaultPreparationSpareTimeStatus.error); + bool get needsReload => + failure == DefaultPreferencesFailure.conflict || + failure == DefaultPreferencesFailure.unavailable; + bool get canSubmit => canEdit && baseline != null && !needsReload; @override - List get props => [status, spareTime, preparation, errorMessage]; + List get props => [ + status, + spareTime, + preparation, + baseline, + failure, + receipt, + editorVersion, + ]; DefaultPreparationSpareTimeFormState copyWith({ DefaultPreparationSpareTimeStatus? status, Duration? spareTime, PreparationEntity? preparation, - String? errorMessage, - }) { - return DefaultPreparationSpareTimeFormState( - status: status ?? this.status, - spareTime: spareTime ?? this.spareTime, - preparation: preparation ?? this.preparation, - errorMessage: errorMessage, - ); - } + DefaultPreferencesFailure? failure, + bool clearFailure = false, + DefaultPreferencesSaveReceipt? receipt, + }) => DefaultPreparationSpareTimeFormState( + status: status ?? this.status, + spareTime: spareTime ?? this.spareTime, + preparation: preparation ?? this.preparation, + baseline: baseline, + failure: clearFailure ? null : failure ?? this.failure, + receipt: receipt ?? this.receipt, + editorVersion: editorVersion, + ); } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart index 6cea983b..dba1b431 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart @@ -2,13 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; -import 'package:on_time_front/presentation/shared/components/time_stepper.dart'; import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; class PreparationSpareTimeEditScreen extends StatelessWidget { @@ -20,11 +19,8 @@ class PreparationSpareTimeEditScreen extends StatelessWidget { providers: [ BlocProvider( create: (context) { - final spareTime = - context.read().state.user.spareTimeOrNull ?? - Duration.zero; return getIt.get() - ..add(FormEditRequested(spareTime: spareTime)); + ..add(const FormEditRequested()); }, ), BlocProvider( @@ -49,21 +45,11 @@ class _PreparationSpareTimeEditView extends StatelessWidget { >( listenWhen: (previous, current) => previous.status != current.status, listener: (context, state) { - if (state.status == DefaultPreparationSpareTimeStatus.submitted) { + final bloc = context.read(); + if (state.status == DefaultPreparationSpareTimeStatus.submitted && + bloc.current && + (ModalRoute.of(context)?.isCurrent ?? false)) { Navigator.of(context).pop(); - } else if (state.status == - DefaultPreparationSpareTimeStatus.error) { - final l10n = AppLocalizations.of(context)!; - showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: state.errorMessage ?? l10n.error, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.destructive, - ), - ), - ); } }, ), @@ -74,7 +60,7 @@ class _PreparationSpareTimeEditView extends StatelessWidget { listenWhen: (previous, current) => current.status == DefaultPreparationSpareTimeStatus.success && current.preparation != null && - previous.preparation != current.preparation, + previous.editorVersion != current.editorVersion, listener: (context, state) { context.read().add( PreparationFormEditRequested( @@ -124,7 +110,6 @@ class _PreparationSpareTimeEditView extends StatelessWidget { .read() .add( FormSubmitted( - note: '', preparation: currentPreparationState .toPreparationEntity(), ), @@ -161,20 +146,41 @@ class _PreparationSpareTimeEditBody extends StatelessWidget { buildWhen: (previous, current) => previous.status != current.status || previous.spareTime != current.spareTime || - previous.preparation != current.preparation, + previous.editorVersion != current.editorVersion, builder: (context, state) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: state.hasEditableData - ? _PreparationSpareTimeEditContent( - key: const ValueKey('preparation_spare_time_form'), - spareTime: state.spareTime!, - ) - : const _PreparationSpareTimeEditLoading( - key: ValueKey('preparation_spare_time_loading'), + if (!state.hasEditableData) { + if (state.status == DefaultPreparationSpareTimeStatus.error) { + return _PreferencesNotice(state: state); + } + return const _PreparationSpareTimeEditLoading(); + } + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: Column( + children: [ + if (state.status == DefaultPreparationSpareTimeStatus.error || + state.status == + DefaultPreparationSpareTimeStatus.followUpPending) + _PreferencesNotice(state: state), + if (state.status == DefaultPreparationSpareTimeStatus.loading || + state.status == + DefaultPreparationSpareTimeStatus.submitting) + const LinearProgressIndicator(), + SizedBox( + height: constraints.maxHeight < 620 + ? 620 + : constraints.maxHeight, + child: AbsorbPointer( + absorbing: !state.canEdit, + child: _PreparationSpareTimeEditContent( + key: const ValueKey('preparation_spare_time_form'), + spareTime: state.spareTime!, + ), + ), ), + ], + ), + ), ); }, ); @@ -182,7 +188,7 @@ class _PreparationSpareTimeEditBody extends StatelessWidget { } class _PreparationSpareTimeEditLoading extends StatelessWidget { - const _PreparationSpareTimeEditLoading({super.key}); + const _PreparationSpareTimeEditLoading(); @override Widget build(BuildContext context) { @@ -238,40 +244,38 @@ class _SpareTimeSection extends StatelessWidget { ), ), SizedBox(height: 24.0), - TimeStepper( - onSpareTimeIncreased: () { - context.read().add( - const SpareTimeIncreased(), - ); - }, - onSpareTimeDecreased: () { - context.read().add( - const SpareTimeDecreased(), - ); - }, - lowerBound: Duration(minutes: 10), - value: spareTime, + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 20, + children: [ + IconButton( + onPressed: spareTime.inMinutes >= 5 + ? () => context + .read() + .add(const SpareTimeDecreased()) + : null, + icon: const Icon(Icons.remove), + ), + Text( + AppLocalizations.of( + context, + )!.defaultPreferencesMinutes(spareTime.inMinutes), + ), + IconButton( + onPressed: spareTime.inMinutes <= 1435 + ? () => context + .read() + .add(const SpareTimeIncreased()) + : null, + icon: const Icon(Icons.add), + ), + ], ), ], ); } } -extension on DefaultPreparationSpareTimeFormState { - bool get hasEditableData => - (status == DefaultPreparationSpareTimeStatus.success || - status == DefaultPreparationSpareTimeStatus.submitting || - status == DefaultPreparationSpareTimeStatus.error) && - spareTime != null && - preparation != null; - - bool get canSubmit => - (status == DefaultPreparationSpareTimeStatus.success || - status == DefaultPreparationSpareTimeStatus.error) && - spareTime != null && - preparation != null; -} - class _PreparationSection extends StatelessWidget { const _PreparationSection({required this.preparationNameState}); @@ -304,7 +308,7 @@ class _PreparationSection extends StatelessWidget { (prev, step) => prev + step.preparationTime.value, ); return Text( - '${AppLocalizations.of(context)!.totalTime}${totalDuration.inMinutes}분', + '${AppLocalizations.of(context)!.totalTime}${AppLocalizations.of(context)!.defaultPreferencesMinutes(totalDuration.inMinutes)}', textAlign: TextAlign.end, ); }, @@ -333,3 +337,75 @@ class _PreparationSection extends StatelessWidget { ); } } + +class _PreferencesNotice extends StatelessWidget { + const _PreferencesNotice({required this.state}); + final DefaultPreparationSpareTimeFormState state; + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final bloc = context.read(); + final receipt = state.receipt; + final String message; + if (receipt != null) { + message = receipt.authorityPending + ? l10n.defaultPreferencesAuthorityPending + : state.needsReload + ? l10n.defaultPreferencesSavedStoreChanged + : receipt.reloadPending && receipt.deliveryPending + ? l10n.defaultPreferencesBothPending + : receipt.reloadPending + ? l10n.defaultPreferencesReloadPending + : l10n.defaultPreferencesDeliveryPending; + } else if (state.needsReload) { + message = l10n.defaultPreferencesConflict; + } else if (!state.hasEditableData) { + message = l10n.defaultPreferencesLoadFailed; + } else if (state.failure == DefaultPreferencesFailure.invalid) { + message = l10n.defaultPreferencesInvalid; + } else { + message = l10n.defaultPreferencesSaveFailed; + } + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message, key: const ValueKey('default_preferences_notice')), + if (receipt != null && !state.needsReload) + TextButton( + onPressed: () => bloc.add(const FormFollowUpRetried()), + child: Text(l10n.defaultPreferencesRetryFollowUp), + ) + else if (receipt == null && + (state.needsReload || !state.hasEditableData)) + TextButton( + onPressed: () async { + final owner = bloc.formOwner; + if (state.hasEditableData) { + final decision = await showTwoActionDialog( + context, + config: TwoActionDialogConfig( + title: l10n.defaultPreferencesDiscardTitle, + description: l10n.defaultPreferencesDiscardDescription, + primaryAction: DialogActionConfig( + label: l10n.defaultPreferencesLoadLatest, + variant: ModalWideButtonVariant.destructive, + ), + secondaryAction: DialogActionConfig(label: l10n.cancel), + ), + ); + if (decision != DialogActionResult.primary) return; + } + if (context.mounted && bloc.ownsForm(owner)) { + bloc.add(const FormEditRequested()); + } + }, + child: Text(l10n.defaultPreferencesLoadLatest), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/recurring/recurrence_components.dart b/lib/presentation/recurring/recurrence_components.dart index 343fca2d..3465263c 100644 --- a/lib/presentation/recurring/recurrence_components.dart +++ b/lib/presentation/recurring/recurrence_components.dart @@ -52,22 +52,22 @@ class RecurrenceSheet extends StatelessWidget { ), child: Column( children: [ - SizedBox( - height: 44, - child: TopBar( - title: title, - showAction: false, - onPreviousPageButtonClicked: showBack - ? onBack ?? () => Navigator.of(context).maybePop() - : null, - onNextPageButtonClicked: null, - isNextButtonEnabled: false, - ), - ), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), children: [ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: TopBar( + title: title, + showAction: false, + onPreviousPageButtonClicked: showBack + ? onBack ?? () => Navigator.of(context).maybePop() + : null, + onNextPageButtonClicked: null, + isNextButtonEnabled: false, + ), + ), for (final child in children) Padding( padding: EdgeInsets.only(bottom: spacing), diff --git a/lib/presentation/recurring/recurrence_occurrence_sheet.dart b/lib/presentation/recurring/recurrence_occurrence_sheet.dart index 051bdad6..8ed562e4 100644 --- a/lib/presentation/recurring/recurrence_occurrence_sheet.dart +++ b/lib/presentation/recurring/recurrence_occurrence_sheet.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:flutter/material.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; @@ -18,7 +19,10 @@ class RecurrenceOccurrenceSheet extends StatelessWidget { final unrecorded = !schedule.isStarted && schedule.doneStatus == ScheduleDoneStatus.notEnded && - schedule.occurrenceInstantUtc.isBefore(DateTime.now().toUtc()); + ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now(), + ).isHistorical; return RecurrenceSheet( title: schedule.scheduleName, footer: ScreenActions( diff --git a/lib/presentation/recurring/recurrence_review_sheet.dart b/lib/presentation/recurring/recurrence_review_sheet.dart index 04183c0e..2d99f8e8 100644 --- a/lib/presentation/recurring/recurrence_review_sheet.dart +++ b/lib/presentation/recurring/recurrence_review_sheet.dart @@ -1,4 +1,10 @@ -import 'package:intl/intl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:on_time_front/core/time/civil_time_resolver.dart'; import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; @@ -21,8 +27,11 @@ class RecurrenceReviewSheet extends StatefulWidget { class _RecurrenceReviewSheetState extends State { final _excluded = {}; + late DateTime _displayNowUtc; @override Widget build(BuildContext context) { + _displayNowUtc = DateTime.now().toUtc(); + final l = AppLocalizations.of(context)!; final review = widget.review; final form = widget.form; final byKey = >{}; @@ -68,6 +77,28 @@ class _RecurrenceReviewSheetState extends State { ? () => Navigator.of(context).pop(Set.from(_excluded)) : null, children: [ + Text( + form.originalSchedule == null + ? l.zonedTimeReviewScopeNew + : form.recurringScope == RecurringEditScope.following + ? l.zonedTimeReviewScopeFollowing + : l.zonedTimeReviewScopeOccurrence, + ), + if (!review.persistentConflict && remaining.isNotEmpty) + RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l.zonedTimeFirstOccurrence), + _slotPresentation(remaining.first), + if (review.occurrences[remaining.first.key] != null) ...[ + const SizedBox(height: 12), + _preparationPresentation(remaining.first), + ], + ], + ), + ), if (review.persistentConflict) ...[ RecurrencePanel( highlighted: true, @@ -98,14 +129,17 @@ class _RecurrenceReviewSheetState extends State { label: recurrenceText(context, '반복 요일', 'Repeat pattern'), value: recurrenceLabel(context, form.recurrenceRule!), ), - RecurrenceValue( - label: form.scheduleName ?? '', - value: recurrenceDate(context, form.scheduleTime!), - ), - for (final conflict in review.conflicts.take(3)) - RecurrenceValue( - label: conflict.other.scheduleName, - value: recurrenceDate(context, conflict.other.scheduleTime), + Text(form.scheduleName ?? ''), + _draftPresentation(), + for (final conflict in review.conflicts) + RecurrencePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(conflict.other.scheduleName), + _schedulePresentation(conflict.other), + ], + ), ), ] else if (byKey.isNotEmpty) ...[ Text( @@ -149,6 +183,7 @@ class _RecurrenceReviewSheetState extends State { padding: EdgeInsets.zero, highlighted: _excluded.contains(slot.key), child: CheckboxListTile( + key: ValueKey('review-occurrence-${slot.key}'), contentPadding: const EdgeInsets.symmetric(horizontal: 12), dense: true, value: _excluded.contains(slot.key), @@ -159,10 +194,7 @@ class _RecurrenceReviewSheetState extends State { : _excluded.remove(slot.key), ) : null, - title: Text( - recurrenceDate(context, _time(slot)), - style: Theme.of(context).textTheme.bodyMedium, - ), + title: _slotPresentation(slot), subtitle: byKey.containsKey(slot.key) ? Text( byKey[slot.key]! @@ -212,14 +244,6 @@ class _RecurrenceReviewSheetState extends State { recurrenceLabel(context, form.recurrenceRule!), style: Theme.of(context).textTheme.titleSmall, ), - if (remaining.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - '${recurrenceText(context, '첫 일정', 'First occurrence')}: ${recurrenceDate(context, _time(remaining.first))}', - style: Theme.of(context).textTheme.bodySmall, - ), - ), const Divider(height: 28), Text( recurrenceText( @@ -228,58 +252,9 @@ class _RecurrenceReviewSheetState extends State { 'Preparation ${form.totalPreparationTime.inMinutes} min · travel ${form.moveTime?.inMinutes ?? 0} min · buffer ${form.scheduleSpareTime?.inMinutes ?? 0} min', ), ), - if (remaining.isNotEmpty && - review.occurrences[remaining.first.key] != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '${recurrenceText(context, '준비 시작', 'Preparation starts')} ${recurrenceDate(context, CivilTimeResolver.civilTimeAt(review.occurrences[remaining.first.key]!.preparationStartUtc, review.occurrences[remaining.first.key]!.schedule.timeZoneId))}', - ), - ), ], ), ), - if (remaining.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - recurrenceText(context, '예정된 날짜', 'Upcoming occurrences'), - style: Theme.of(context).textTheme.titleSmall, - ), - RecurrencePanel( - child: Column( - children: [ - for (final slot in remaining.take(3)) ...[ - if (slot != remaining.first) const Divider(height: 16), - Align( - alignment: Alignment.centerLeft, - child: Row( - children: [ - Expanded( - child: Text( - DateFormat( - 'M/d (E)', - Localizations.localeOf(context).toString(), - ).format(_time(slot)), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - Text( - DateFormat.jm( - Localizations.localeOf(context).toString(), - ).format(_time(slot)), - style: const TextStyle(fontSize: 12), - ), - ], - ), - ), - ], - ], - ), - ), - ], ], if (review.detached.isNotEmpty) ...[ Text( @@ -290,9 +265,14 @@ class _RecurrenceReviewSheetState extends State { ), ), for (final value in review.detached) - RecurrenceValue( - label: value.scheduleName, - value: recurrenceDate(context, value.scheduleTime), + RecurrencePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value.scheduleName), + _schedulePresentation(value), + ], + ), ), RecurrencePanel( highlighted: true, @@ -315,6 +295,21 @@ class _RecurrenceReviewSheetState extends State { style: Theme.of(context).textTheme.bodySmall, ), ], + if (!review.persistentConflict && remaining.isNotEmpty) + ExpansionTile( + key: const ValueKey('all-reviewed-occurrences'), + title: Text(l.zonedTimeAllReviewedOccurrences), + children: [ + for (final slot in remaining) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: _slotPresentation(slot), + ), + ], + ), if (review.skipped.isNotEmpty) ...[ RecurrencePanel( child: Column( @@ -366,9 +361,93 @@ class _RecurrenceReviewSheetState extends State { ); } - DateTime _time(RecurrenceSlot slot) => - widget.review.occurrences[slot.key]?.schedule.scheduleTime ?? - slot.civilTime; + Widget _draftPresentation() { + final form = widget.form; + // This is a presentation-only snapshot. Reuse the common resolver so an + // obsolete explicit offset remains changed rather than selecting a new one. + final preview = ScheduleEntity( + id: form.id, + place: PlaceEntity( + id: form.placeId ?? '', + placeName: form.placeName ?? '', + ), + scheduleName: form.scheduleName ?? '', + scheduleTime: form.scheduleTime!, + timeZoneId: form.timeZoneId, + occurrenceOffsetSeconds: form.occurrenceOffsetSeconds, + moveTime: form.moveTime ?? Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: form.scheduleSpareTime, + scheduleNote: '', + ); + return _schedulePresentation(preview); + } + + Widget _schedulePresentation(ScheduleEntity schedule) => ScheduleZonedTime( + civil: CivilDateTime.fromFields(schedule.scheduleTime), + timeZoneId: schedule.timeZoneId, + resolution: ScheduleTimeResolver.resolve(schedule, nowUtc: _displayNowUtc), + showInstant: true, + ); + + Widget _slotPresentation(RecurrenceSlot slot) { + final schedule = widget.review.occurrences[slot.key]?.schedule; + return ScheduleZonedTime( + showInstant: true, + civil: CivilDateTime.fromFields(schedule?.scheduleTime ?? slot.civilTime), + timeZoneId: schedule?.timeZoneId ?? widget.form.timeZoneId, + resolution: schedule == null + ? ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: slot.instantUtc, + ) + : ScheduleTimeResolver.resolve(schedule, nowUtc: _displayNowUtc), + ); + } + + Widget _preparationPresentation(RecurrenceSlot slot) { + final l = AppLocalizations.of(context)!; + final occurrence = widget.review.occurrences[slot.key]!; + final zone = occurrence.schedule.timeZoneId; + CivilDateTime? civil; + final resolution = ScheduleTimeResolver.resolve( + occurrence.schedule, + nowUtc: _displayNowUtc, + ); + if (resolution.status == ScheduleTimeResolutionStatus.resolved && + TimeZoneRules.contains(zone)) { + try { + civil = CivilDateTime.fromFields( + CivilTimeResolver.civilTimeAt(occurrence.preparationStartUtc, zone), + ); + } on Exception { + // The reviewed instant remains meaningful even if a rule reload or + // the supported civil range now prevents a safe named-zone display. + } + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l.zonedTimePreparationStart), + if (civil == null) ...[ + Text(l.zonedTimePreparationUnavailable), + Text( + '$zone · ${l.zonedTimeInstant}: ${occurrence.preparationStartUtc.toIso8601String()}', + ), + ] else + ScheduleZonedTime( + civil: civil, + timeZoneId: zone, + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: occurrence.preparationStartUtc, + ), + showInstant: true, + ), + ], + ); + } String _skip(BuildContext context, RecurrenceSkip skip) => '${recurrenceDate(context, skip.date)} · ${switch (skip.reason) { diff --git a/lib/presentation/recurring/recurrence_settings_sheet.dart b/lib/presentation/recurring/recurrence_settings_sheet.dart index fcfb1023..d8b5b14b 100644 --- a/lib/presentation/recurring/recurrence_settings_sheet.dart +++ b/lib/presentation/recurring/recurrence_settings_sheet.dart @@ -1,4 +1,5 @@ import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; @@ -67,9 +68,14 @@ class _RecurrenceSettingsSheetState extends State { : r?.until != null ? 1 : 0; + final defaultUntil = DateTime.utc( + widget.start.year, + widget.start.month + 3, + widget.start.day, + ); _until = r?.until ?? - DateTime(widget.start.year, widget.start.month + 3, widget.start.day); + (defaultUntil.year > 9999 ? DateTime.utc(9999, 12, 31) : defaultUntil); } @override @@ -168,7 +174,13 @@ class _RecurrenceSettingsSheetState extends State { final slots = const RecurrenceEngine() .expand( r, - through: r.until ?? DateTime(widget.start.year + 50, 12, 31), + through: + r.until ?? + DateTime.utc( + widget.start.year > 9949 ? 9999 : widget.start.year + 50, + 12, + 31, + ), preparationNotBeforeUtc: DateTime.now(), leadTime: widget.leadTime, limit: 1, @@ -630,18 +642,33 @@ class _RecurrenceSettingsSheetState extends State { value: recurrenceDay(context, _until), icon: Icons.calendar_today_outlined, onTap: () async { - final day = DateTime( + final day = DateTime.utc( widget.start.year, widget.start.month, widget.start.day, ); - final picked = await showDatePicker( + final picked = await showCivilDateTimePicker( context: context, - initialDate: _until.isBefore(day) ? day : _until, - firstDate: day, - lastDate: DateTime(9999, 12, 31), + initialCivil: _until, + dateOnly: true, + title: recurrenceText(context, '마지막 날짜', 'Inclusive end date'), ); - if (picked != null && mounted) setState(() => _until = picked); + if (picked == null || !mounted) return; + final selected = RecurrenceRule.civilDate(picked); + if (selected.isBefore(day)) { + setState( + () => _error = recurrenceText( + context, + '종료 날짜는 시작 날짜보다 빠를 수 없어요.', + 'The end date cannot precede the start date.', + ), + ); + return; + } + setState(() { + _until = selected; + _error = null; + }); }, ), RecurrencePanel( diff --git a/lib/presentation/recurring/recurrence_time_choice_sheet.dart b/lib/presentation/recurring/recurrence_time_choice_sheet.dart index ef6e68ad..9fb06e48 100644 --- a/lib/presentation/recurring/recurrence_time_choice_sheet.dart +++ b/lib/presentation/recurring/recurrence_time_choice_sheet.dart @@ -1,3 +1,7 @@ +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; import 'package:flutter/material.dart'; import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; @@ -18,6 +22,24 @@ class RecurrenceTimeChoiceSheet extends StatefulWidget { class _RecurrenceTimeChoiceSheetState extends State { RepeatedCivilTime _choice = RepeatedCivilTime.first; + Widget _candidatePreview(RepeatedCivilTime choice) { + final candidates = CivilTimeResolver.resolve( + widget.date, + widget.timeZoneId, + ); + final index = choice == RepeatedCivilTime.first ? 0 : 1; + if (candidates.length <= index) return const SizedBox.shrink(); + return ScheduleZonedTime( + showInstant: true, + civil: CivilDateTime.fromFields(widget.date), + timeZoneId: widget.timeZoneId, + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: candidates[index].instantUtc, + ), + ); + } + @override Widget build(BuildContext context) => RecurrenceSheet( title: recurrenceText(context, '반복 시각 확인', 'Repeated time'), @@ -47,6 +69,7 @@ class _RecurrenceTimeChoiceSheetState extends State { ), selected: _choice == choice, onTap: () => setState(() => _choice = choice), + child: _candidatePreview(choice), ), RecurrencePanel( child: Text( diff --git a/lib/presentation/recurring/recurring_management_screen.dart b/lib/presentation/recurring/recurring_management_screen.dart index 5eae2cd4..f654c10a 100644 --- a/lib/presentation/recurring/recurring_management_screen.dart +++ b/lib/presentation/recurring/recurring_management_screen.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/presentation/calendar/component/schedule_deletion_dialog.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; @@ -188,7 +189,6 @@ class _SeriesDetail extends StatefulWidget { class _SeriesDetailState extends State<_SeriesDetail> { bool _busy = false; - String? _error; Future _edit() async { final saved = await showModalBottomSheet( context: context, @@ -202,88 +202,18 @@ class _SeriesDetailState extends State<_SeriesDetail> { } Future _end() async { - final next = widget.summary.next!; - final confirmed = await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .94, - child: RecurrenceSheet( - title: recurrenceText(context, '반복 종료', 'End series'), - footer: ScreenActions( - action: recurrenceText(context, '반복 종료', 'End series'), - destructive: true, - backLabel: recurrenceText(context, '취소', 'Cancel'), - onBack: () => Navigator.pop(context, false), - onAction: () => Navigator.pop(context, true), - ), - children: [ - const SizedBox(height: 16), - Text( - recurrenceText( - context, - '${recurrenceDay(context, next.scheduleTime)}부터 이후 회차의 반복을 종료할까요?', - 'End the series from ${recurrenceDay(context, next.scheduleTime)}?', - ), - style: Theme.of(context).textTheme.titleSmall, - ), - Text( - recurrenceText( - context, - '아직 시작하지 않은 이후 회차는 모두 삭제됩니다. 이미 진행 중이거나 완료된 기록은 그대로 유지됩니다.', - 'Deletes upcoming occurrences. Active preparations and history are preserved.', - ), - ), - RecurrencePanel( - highlighted: true, - child: Text( - recurrenceText( - context, - '개별로 수정한 이후 회차도 삭제 대상에 포함됩니다.', - 'Individually edited following occurrences are also deleted.', - ), - ), - ), - RecurrenceValue( - label: recurrenceText(context, '종료되는 범위', 'Deleted occurrences'), - value: recurrenceText( - context, - '다음 회차부터 이후의 모든 회차', - 'The next and all following occurrences', - ), - ), - RecurrenceValue( - label: recurrenceText(context, '계속 유지되는 항목', 'Preserved'), - value: recurrenceText( - context, - '지난 기록과 현재 진행 중인 준비', - 'History and active preparations', - ), - ), - ], - ), - ), - ); - if (confirmed != true || !mounted) return; + if (_busy) return; setState(() => _busy = true); try { - await widget.useCase.delete( - widget.summary.next!, - RecurringEditScope.following, + final changed = await showScheduleDeletionDialog( + context, + schedule: widget.summary.next!, + deletions: widget.useCase.deletions, + scope: RecurringEditScope.following, ); - if (mounted) Navigator.of(context).pop(true); - } catch (_) { - if (mounted) { - setState(() { - _busy = false; - _error = recurrenceText( - context, - '종료하지 못했어요. 다시 시도해 주세요.', - 'Could not end this series. Please retry.', - ); - }); - } + if (changed && mounted) Navigator.of(context).pop(true); + } finally { + if (mounted) setState(() => _busy = false); } } @@ -379,14 +309,8 @@ class _SeriesDetailState extends State<_SeriesDetail> { layout: ModalWideButtonLayout.full, height: 48, onPressed: _busy ? null : _end, - isLoading: _busy, ), ], - if (_error != null) - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), ], ); } diff --git a/lib/presentation/recurring/recurring_time_correction_plan_summary.dart b/lib/presentation/recurring/recurring_time_correction_plan_summary.dart new file mode 100644 index 00000000..7c92df6a --- /dev/null +++ b/lib/presentation/recurring/recurring_time_correction_plan_summary.dart @@ -0,0 +1,399 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'recurrence_labels.dart'; + +/// Read-only plan details and controlled acknowledgements, not save authority. +/// The owning screen must reset acknowledgements whenever its review changes, +/// complete conflict review, and revalidate the exact review before saving. +/// Place inside a vertically scrollable parent. +class RecurringTimeCorrectionPlanSummary extends StatelessWidget { + const RecurringTimeCorrectionPlanSummary({ + super.key, + required this.mapping, + required this.unresolvedOverrides, + required this.confirmedDetachedIds, + required this.confirmedUnmatchedExclusions, + required this.onDetachedChanged, + required this.onUnmatchedExclusionsChanged, + this.onResolveOverride, + this.enabled = true, + }); + + final RecurringTimeCorrectionMapping mapping; + final Map unresolvedOverrides; + final Set confirmedDetachedIds; + final Set confirmedUnmatchedExclusions; + final ValueChanged> onDetachedChanged; + final ValueChanged> onUnmatchedExclusionsChanged; + final ValueChanged? onResolveOverride; + final bool enabled; + + static String exclusionKey(TimeCorrectionExclusion value) => + '${value.segmentId}\n${value.slot}'; + + String _recorded(BuildContext context, ScheduleEntity value) { + final offset = value.occurrenceOffsetSeconds; + return '${recurrenceDate(context, value.scheduleTime)} · ${value.timeZoneId}\n' + '${recurrenceText(context, '기록된 UTC 오프셋', 'Recorded UTC offset')}: ' + '${offset == null ? recurrenceText(context, '미확정', 'Unconfirmed') : _offset(offset)}' + '${value.recurringSlotKey == null ? '' : '\n${recurrenceText(context, '기존 회차', 'Original slot')}: ${recurrenceDate(context, CivilDateTime.parse(value.recurringSlotKey!).toUtcCarrier())} · #${value.recurringOrdinal}'}'; + } + + String _offset(int seconds) { + final absolute = seconds.abs(); + final hours = (absolute ~/ 3600).toString().padLeft(2, '0'); + final minutes = (absolute % 3600 ~/ 60).toString().padLeft(2, '0'); + final remainder = absolute % 60; + return '${seconds < 0 ? '-' : '+'}$hours:$minutes' + '${remainder == 0 ? '' : ':${remainder.toString().padLeft(2, '0')}'}'; + } + + String _reason(BuildContext context, ScheduleTimeResolutionStatus status) => + switch (status) { + ScheduleTimeResolutionStatus.unknownZone => recurrenceText( + context, + '알 수 없는 시간대', + 'Unknown time zone', + ), + ScheduleTimeResolutionStatus.nonexistent => recurrenceText( + context, + '존재하지 않는 현지 시각', + 'Nonexistent local time', + ), + ScheduleTimeResolutionStatus.ambiguous => recurrenceText( + context, + '두 번 반복되는 현지 시각', + 'Repeated local time', + ), + ScheduleTimeResolutionStatus.changed => recurrenceText( + context, + '시간대 규칙 변경', + 'Changed time-zone rules', + ), + ScheduleTimeResolutionStatus.historicalUncertain => recurrenceText( + context, + '과거 시각 미확정', + 'Uncertain historical time', + ), + ScheduleTimeResolutionStatus.invalid => recurrenceText( + context, + '시각 확인 필요', + 'Time review required', + ), + ScheduleTimeResolutionStatus.resolved => recurrenceText( + context, + '별도 검토 결과 확인 필요', + 'Individual review result required', + ), + }; + + @override + Widget build(BuildContext context) { + String text(String ko, String en) => recurrenceText(context, ko, en); + final mapped = mapping.rows.where((row) => !row.detached).toList(); + final detached = mapping.rows.where((row) => row.detached).toList(); + final unmatched = mapping.exclusions + .where((row) => row.slot == null) + .toList(); + final matched = mapping.exclusions + .where((row) => row.slot != null) + .toList(); + final detachedIds = detached.map((row) => row.original.id).toSet(); + final unmatchedKeys = unmatched + .map((row) => exclusionKey(row.original)) + .toSet(); + final schedules = { + for (final row in mapping.rows) row.original.id: row.original, + for (final row in mapping.protectedRows) row.id: row, + }; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + text('반복 일정 변경 내용', 'Recurring schedule changes'), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 12), + Text( + '${recurrenceLabel(context, mapping.rule)}\n${mapping.rule.timeZoneId} · ${recurrenceEndLabel(context, mapping.rule)}', + ), + const SizedBox(height: 8), + Text( + text( + '새 반복 횟수에는 제외한 회차도 포함됩니다. 단발로 남길 일정 ${detached.length}개는 별도로 유지됩니다.', + 'The new occurrence count includes excluded slots. ${detached.length} standalone schedules are preserved separately.', + ), + ), + if (mapping.automaticallyRetainedCount) + Text( + text( + '남은 횟수는 기존 기록과 현재 규칙으로 계산했습니다. 과거 시간대 규칙을 재현했다는 뜻은 아닙니다.', + 'The remaining count uses stored records and current rules; it does not reconstruct historical time-zone rules.', + ), + ), + Text( + text( + '일정의 연결 관계와 별도로 충돌 검사를 확인해야 합니다. 이 목록만으로 저장 가능 여부가 확정되지는 않습니다.', + 'Conflict review is separate from these schedule associations. This list alone does not establish that the plan can be saved.', + ), + ), + if (unresolvedOverrides.isNotEmpty) + _PlanSection>( + key: const Key('plan-unresolved'), + owner: mapping, + title: text( + '먼저 개별 시간 확인 (${unresolvedOverrides.length})', + 'Individual time review first (${unresolvedOverrides.length})', + ), + values: unresolvedOverrides.entries.toList(), + builder: (entry) { + final schedule = schedules[entry.key]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + title: Text( + schedule?.scheduleName ?? + text('연결된 일정', 'Associated schedule'), + ), + subtitle: Text( + '${_reason(context, entry.value)}\n${text('이 일정의 시각을 확인한 후 전체 계획을 다시 검토하세요.', 'Review this time, then review the complete plan again.')}', + ), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + key: ValueKey('resolve-${entry.key}'), + onPressed: enabled && onResolveOverride != null + ? () => onResolveOverride!(entry.key) + : null, + child: Text(text('이 일정 시간 확인', 'Review this time')), + ), + ), + ], + ); + }, + ), + _PlanSection( + key: const Key('plan-mapped'), + owner: mapping, + title: text( + '새 회차에 연결 (${mapped.length})', + 'Associated with new slots (${mapped.length})', + ), + values: mapped, + builder: (row) { + final slot = row.slot!; + final keepsTime = row.original.recurringOverrides + .split(',') + .contains('time'); + return ListTile( + key: ValueKey('mapped-${row.original.id}'), + title: Text(row.original.scheduleName), + subtitle: Text( + '${text('원래 일정', 'Original schedule')}: ${_recorded(context, row.original)}\n' + '${text('새 회차 연결', 'New slot association')}: ${recurrenceDate(context, slot.civilTime)} · ${mapping.rule.timeZoneId} · #${slot.ordinal}\n' + '${keepsTime ? text('개별 지정한 약속 시각과 시간대를 그대로 유지합니다. 위 시각은 새 회차의 연결 기준입니다.', 'The individually chosen appointment time and zone stay unchanged. The time above identifies the new slot.') : '${text('새 약속 시각', 'New appointment instant')}: ${slot.instantUtc.toUtc().toIso8601String()} · ${text('선택 오프셋', 'Selected offset')}: UTC${_offset(slot.offsetSeconds)}'}', + ), + ); + }, + ), + if (detached.isNotEmpty) + _PlanSection( + key: const Key('plan-detached'), + owner: mapping, + title: text( + '단발로 유지할 일정 (${detached.length})', + 'Preserve as standalone (${detached.length})', + ), + values: detached, + builder: (row) => CheckboxListTile( + key: ValueKey('detached-${row.original.id}'), + controlAffinity: ListTileControlAffinity.leading, + value: confirmedDetachedIds.contains(row.original.id), + title: Text(row.original.scheduleName), + subtitle: Text( + '${_recorded(context, row.original)}\n${text('이 일정을 삭제하지 않고 내용과 준비 항목을 그대로 유지하는 데 동의합니다.', 'I agree to preserve this schedule and its preparation unchanged, without deleting it.')}', + ), + onChanged: enabled + ? (checked) { + final next = confirmedDetachedIds.intersection( + detachedIds, + ); + if (checked == true) { + next.add(row.original.id); + } else { + next.remove(row.original.id); + } + onDetachedChanged(Set.unmodifiable(next)); + } + : null, + ), + ), + if (mapping.protectedRows.isNotEmpty) + _PlanSection( + key: const Key('plan-protected'), + owner: mapping, + title: text( + '변경하지 않는 기록 (${mapping.protectedRows.length})', + 'Records kept unchanged (${mapping.protectedRows.length})', + ), + values: mapping.protectedRows, + builder: (row) => ListTile( + title: Text(row.scheduleName), + subtitle: Text(_recorded(context, row)), + ), + ), + if (mapping.protectedSlots.isNotEmpty) + Text( + text( + '보호된 기록에 대응하는 새 회차 ${mapping.protectedSlots.length}개는 제외해 중복 생성을 막습니다.', + '${mapping.protectedSlots.length} new slots corresponding to protected records are excluded to prevent duplicate occurrences.', + ), + ), + if (matched.isNotEmpty) + _PlanSection( + key: const Key('plan-matched-exclusions'), + owner: mapping, + title: text( + '계속 제외할 회차 (${matched.length})', + 'Slots that remain excluded (${matched.length})', + ), + values: matched, + builder: (row) => ListTile( + title: Text( + recurrenceDate( + context, + CivilDateTime.parse(row.original.slot).toUtcCarrier(), + ), + ), + subtitle: Text( + '${text('새 회차 연결', 'New slot association')}: ${recurrenceDate(context, row.slot!.civilTime)} · #${row.slot!.ordinal}\n${text('원래 제외 기록을 보존하며 새 반복 횟수에서도 한 회차로 계산합니다.', 'The original exclusion is preserved; this slot also consumes one occurrence in the new count.')}', + ), + ), + ), + if (unmatched.isNotEmpty) + _PlanSection( + key: const Key('plan-unmatched-exclusions'), + owner: mapping, + title: text( + '새 규칙에 대응하지 않는 제외 기록 (${unmatched.length})', + 'Exclusions outside the new rule (${unmatched.length})', + ), + values: unmatched, + builder: (row) { + final id = exclusionKey(row.original); + return CheckboxListTile( + key: ValueKey('exclusion-$id'), + controlAffinity: ListTileControlAffinity.leading, + value: confirmedUnmatchedExclusions.contains(id), + title: Text( + recurrenceDate( + context, + CivilDateTime.parse(row.original.slot).toUtcCarrier(), + ), + ), + subtitle: Text( + text( + '원래 제외 기록은 보존되지만 새 규칙에는 적용되지 않음을 확인했습니다. 삭제했던 회차를 다시 만들지 않습니다.', + 'I understand that the original exclusion is preserved but does not apply to the new rule. The deleted occurrence is not recreated.', + ), + ), + onChanged: enabled + ? (checked) { + final next = confirmedUnmatchedExclusions.intersection( + unmatchedKeys, + ); + if (checked == true) { + next.add(id); + } else { + next.remove(id); + } + onUnmatchedExclusionsChanged(Set.unmodifiable(next)); + } + : null, + ); + }, + ), + ], + ); + } +} + +class _PlanSection extends StatefulWidget { + const _PlanSection({ + super.key, + required this.owner, + required this.title, + required this.values, + required this.builder, + }); + final Object owner; + final String title; + final List values; + final Widget Function(T) builder; + + @override + State<_PlanSection> createState() => _PlanSectionState(); +} + +class _PlanSectionState extends State<_PlanSection> { + static const pageSize = 10; + int _start = 0; + + @override + void didUpdateWidget(covariant _PlanSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.owner, widget.owner)) _start = 0; + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (widget.values.isEmpty) Text(recurrenceText(context, '없음', 'None')), + for (final value in widget.values.skip(_start).take(pageSize)) + widget.builder(value), + if (widget.values.length > pageSize) ...[ + Text( + '${_start + 1}–${(_start + pageSize).clamp(0, widget.values.length)} / ${widget.values.length}', + ), + Wrap( + spacing: 8, + children: [ + TextButton( + key: const Key('plan-page-previous'), + onPressed: _start == 0 + ? null + : () => setState(() => _start -= pageSize), + child: Text(recurrenceText(context, '이전 항목', 'Previous items')), + ), + TextButton( + key: const Key('plan-page-next'), + onPressed: _start + pageSize >= widget.values.length + ? null + : () => setState(() => _start += pageSize), + child: Text(recurrenceText(context, '다음 항목', 'Next items')), + ), + ], + ), + ], + ], + ), + ); +} diff --git a/lib/presentation/recurring/recurring_time_correction_screen.dart b/lib/presentation/recurring/recurring_time_correction_screen.dart new file mode 100644 index 00000000..fea996cc --- /dev/null +++ b/lib/presentation/recurring/recurring_time_correction_screen.dart @@ -0,0 +1,688 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'package:on_time_front/domain/use-cases/recurring_time_correction_workflow.dart'; +import 'package:on_time_front/domain/use-cases/schedule_time_correction_workflow.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_time_correction_screen.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; +import 'recurrence_labels.dart'; +import 'recurrence_settings_sheet.dart'; +import 'recurring_time_correction_plan_summary.dart'; + +/// Active-store controller. Backup candidates use their own lease/controller. +class RecurringTimeCorrectionScreen extends StatefulWidget { + const RecurringTimeCorrectionScreen({ + super.key, + required this.scheduleId, + required this.workflow, + required this.individualWorkflow, + this.onSaved, + }); + final String scheduleId; + final RecurringTimeCorrectionWorkflow workflow; + final ScheduleTimeCorrectionWorkflow individualWorkflow; + final VoidCallback? onSaved; + @override + State createState() => + _RecurringTimeCorrectionScreenState(); +} + +class _RecurringTimeCorrectionScreenState + extends State { + ScheduleTimeCorrectionReview? _anchor; + RecurrenceRule? _rule; + RecurringTimeCorrectionReview? _review; + RecurringTimeCorrectionCommand? _command; + ScheduleSaveReceipt? _receipt; + Set _excluded = {}, _detached = {}, _unmatched = {}, _uncertain = {}; + bool _explicitEnd = false, + _busy = false, + _confirming = false, + _needsReview = false; + String? _error; + int _owner = 0, _selection = 0, _conflictPage = 0, _possiblePage = 0; + bool _owns(int owner, int selection) => + mounted && owner == _owner && selection == _selection; + String text(String ko, String en) => recurrenceText(context, ko, en); + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(RecurringTimeCorrectionScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scheduleId != widget.scheduleId || + oldWidget.workflow != widget.workflow || + oldWidget.individualWorkflow != widget.individualWorkflow) { + _load(); + } + } + + void _invalidate() { + _selection++; + _review = null; + _command = null; + _receipt = null; + _detached = {}; + _unmatched = {}; + _uncertain = {}; + _conflictPage = 0; + _possiblePage = 0; + _error = null; + _needsReview = false; + } + + Future _load() async { + final owner = ++_owner; + setState(() { + _invalidate(); + _busy = true; + _confirming = false; + _anchor = null; + _rule = null; + _excluded = {}; + _explicitEnd = false; + }); + try { + final anchor = await widget.individualWorkflow.repository.review( + widget.scheduleId, + ); + if (!mounted || owner != _owner) return; + final source = anchor.snapshot.segment; + if (source == null) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.invalid); + } + final start = CivilDateTime.parse( + anchor.snapshot.schedule.recurringSlotKey!, + ).toUtcCarrier(); + setState(() { + _anchor = anchor; + _rule = source.rule.withStartAndEnd( + start: start, + count: source.rule.count, + until: source.rule.until, + repeatedTime: source.rule.repeatedTime, + ); + _busy = false; + }); + } catch (_) { + if (!mounted || owner != _owner) return; + setState(() { + _busy = false; + _needsReview = true; + _error = text( + '현재 반복 일정을 읽지 못했어요. 다시 확인해주세요.', + 'The recurring schedule could not be read. Review it again.', + ); + }); + } + } + + RecurrenceRule _changedRule({ + DateTime? start, + String? zone, + RepeatedCivilTime? repeated, + bool replaceRepeated = false, + }) { + final r = _rule!; + return RecurrenceRule( + frequency: r.frequency, + start: start ?? r.start, + timeZoneId: zone ?? r.timeZoneId, + interval: r.interval, + weekdays: r.weekdays, + monthly: r.monthly, + monthDay: r.monthDay, + ordinal: r.ordinal, + monthWeekday: r.monthWeekday, + until: r.until, + count: r.count, + repeatedTime: replaceRepeated ? repeated : r.repeatedTime, + ); + } + + Future _pickCivil(bool dateOnly) async { + final owner = _owner, selection = _selection; + final result = await showCivilDateTimePicker( + context: context, + initialCivil: _rule!.start, + dateOnly: dateOnly, + title: text('새 반복 시작 시각', 'New recurring start'), + ); + if (result == null || !_owns(owner, selection)) return; + try { + final rule = _changedRule(start: result); + setState(() { + _invalidate(); + _rule = rule; + _excluded = {}; + }); + } on ArgumentError { + setState( + () => _error = text( + '종료 날짜 이후로 시작할 수 없어요. 반복 종료 조건을 먼저 바꿔주세요.', + 'The start cannot follow the end date. Change the end condition first.', + ), + ); + } + } + + Future _pickZone() async { + final owner = _owner, selection = _selection; + final result = await showScheduleTimeZonePicker( + context: context, + currentZone: _rule!.timeZoneId, + civil: _rule!.start, + offsetSeconds: null, + isCurrent: () => _owns(owner, selection), + ); + if (result == null || !_owns(owner, selection)) return; + setState(() { + final rule = _changedRule(zone: result, replaceRepeated: true); + _invalidate(); + _rule = rule; + _excluded = {}; + }); + } + + Future _pickRule() async { + final owner = _owner, selection = _selection; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => RecurrenceSettingsSheet( + start: _rule!.start, + timeZoneId: _rule!.timeZoneId, + initial: _rule, + allowNone: false, + leadTime: _anchor!.snapshot.segment!.leadTime, + ), + ); + if (result?.rule == null || !_owns(owner, selection)) return; + setState(() { + _invalidate(); + _rule = result!.rule; + _explicitEnd = true; + _excluded = {}; + }); + } + + Future _buildPlan({Set? excluded}) async { + if (_busy || _confirming || _anchor == null || _rule == null) return; + setState(() { + _invalidate(); + if (excluded != null) _excluded = Set.of(excluded); + _busy = true; + }); + final owner = _owner, selection = _selection; + try { + final review = await widget.workflow.repository.review( + RecurringTimeCorrectionRequest( + anchorReview: _anchor!, + rule: _rule!, + endExplicitlyChosen: _explicitEnd, + excludedConflictSlots: _excluded, + ), + ); + if (!_owns(owner, selection)) return; + setState(() { + _review = review; + _busy = false; + }); + } on RecurringTimeCorrectionCountRequired { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _error = text( + '남은 횟수를 확정할 수 없어요. 반복 설정에서 횟수나 종료 조건을 직접 확인해주세요.', + 'The remaining count cannot be established. Explicitly choose the count or end condition in recurrence settings.', + ); + }); + } catch (_) { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _error = text( + '전체 계획을 검증하지 못했어요. 시각·규칙·기록을 다시 확인해주세요. 변경은 저장되지 않았어요.', + 'The complete plan could not be verified. Review its time, rule and records. No changes were saved.', + ); + }); + } + } + + Future _resolveOne(String id) async { + final owner = _owner, selection = _selection; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (routeContext) => Scaffold( + appBar: AppBar( + title: Text(text('개별 시간 확인', 'Individual time review')), + ), + body: ScheduleTimeCorrectionScreen( + scheduleId: id, + workflow: widget.individualWorkflow, + onSaved: () => Navigator.of(routeContext).pop(true), + ), + ), + ), + ); + if (!_owns(owner, selection)) return; + // The original plan and every acknowledgement lose authority even if the + // individual screen was cancelled; rebuild from an actual fresh baseline. + await _load(); + } + + bool get _canConfirm { + final review = _review, proof = review?.conflictProof; + if (_busy || + _confirming || + _needsReview || + _receipt != null || + review == null || + proof == null || + proof.conflicts.isNotEmpty || + review.unresolvedOverrides.isNotEmpty) { + return false; + } + return const SetEquality().equals( + _detached, + review.mapping.rows + .where((e) => e.detached) + .map((e) => e.original.id) + .toSet(), + ) && + const SetEquality().equals( + _unmatched, + review.mapping.exclusions + .where((e) => e.slot == null) + .map( + (e) => + RecurringTimeCorrectionPlanSummary.exclusionKey(e.original), + ) + .toSet(), + ) && + const SetEquality().equals( + _uncertain, + proof.possibleOverlaps.map((e) => e.id).toSet(), + ); + } + + Future _confirm() async { + if (!_canConfirm) return; + final owner = _owner, selection = _selection; + final command = _command ??= RecurringTimeCorrectionCommand( + review: _review!, + mutationId: const Uuid().v7(), + confirmedDetachedIds: _detached, + confirmedUnmatchedExclusions: _unmatched, + acknowledgedUncertainIds: _uncertain, + ); + setState(() => _confirming = true); + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(text('반복 시간 변경을 확인하세요', 'Confirm recurring time changes')), + content: SingleChildScrollView( + child: Text( + text( + '확인한 연결·단발 보존·제외·불확실 영향 목록을 적용합니다. 보호된 과거 기록은 유지됩니다. 알림 동기화가 남으면 별도로 표시합니다.', + 'Apply the reviewed associations, standalone preservation, exclusions and uncertain impacts. Protected history is preserved. Any pending notification synchronization is shown separately.', + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(text('취소', 'Cancel')), + ), + FilledButton( + key: const Key('confirm-recurring-time'), + onPressed: () => Navigator.pop(context, true), + child: Text(text('확인 후 저장', 'Confirm and save')), + ), + ], + ), + ); + if (!_owns(owner, selection)) return; + setState(() => _confirming = false); + if (accepted != true) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final receipt = await widget.workflow.confirm(command); + if (_owns(owner, selection)) _saved(receipt); + } on ScheduleSaveRejected { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _needsReview = true; + _error = text( + '기록이나 시간 기준이 달라졌어요. 최신 기록으로 새 계획을 확인해주세요.', + 'The record or time authority changed. Review a new plan from the latest record.', + ); + }); + } on ScheduleTimeCorrectionConflict { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _needsReview = true; + _error = text( + '충돌 영향이 달라졌어요. 계획을 다시 확인해주세요.', + 'Overlap impacts changed. Review the plan again.', + ); + }); + } catch (_) { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _error = text( + '결과를 확인하지 못했어요. 다시 저장하면 동일한 확인 명령의 결과를 조회합니다.', + 'The result could not be confirmed. Saving again retries the exact confirmed command.', + ); + }); + } + } + + void _saved(ScheduleSaveReceipt receipt) { + setState(() { + _busy = false; + _receipt = receipt; + }); + if (!receipt.deliveryPending) { + if (widget.onSaved != null) { + widget.onSaved!(); + } else { + Navigator.of(context).pop(true); + } + } + } + + Future _retryDelivery() async { + final owner = _owner, selection = _selection; + setState(() => _busy = true); + final receipt = await widget.workflow.retryDelivery(_receipt!); + if (_owns(owner, selection)) _saved(receipt); + } + + String _reason(String reason) => switch (reason) { + 'unknownZone' => text('알 수 없는 시간대', 'Unknown time zone'), + 'ambiguous' => text( + '반복되는 현지 시각의 선택 필요', + 'A repeated wall time needs an occurrence choice', + ), + 'nonexistent' => text('존재하지 않는 현지 시각', 'Nonexistent wall time'), + 'changed' => text('시간대 규칙 변경 확인 필요', 'Changed time zone rules need review'), + 'historicalUncertain' => text('역사적 순간 불확실', 'Historical instant uncertain'), + _ => text('정확한 순간 확인 필요', 'The exact instant needs review'), + }; + @override + Widget build(BuildContext context) { + final rule = _rule, review = _review; + final enabled = !_busy && !_confirming && _receipt == null; + final proof = review?.conflictProof; + final conflicts = proof?.conflicts ?? []; + final offset = _conflictPage * 10; + final possible = proof?.possibleOverlaps ?? []; + final possibleOffset = _possiblePage * 10; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Text( + text('이 회차 이후 시간 확인', 'Review time from this occurrence'), + style: Theme.of(context).textTheme.titleLarge, + ), + if (_anchor != null) Text(_anchor!.snapshot.schedule.scheduleName), + if (_busy) const LinearProgressIndicator(), + if (_error != null) + Text(_error!, key: const Key('recurring-time-error')), + TextButton( + key: const Key('reload-recurring-time'), + onPressed: enabled ? _load : null, + child: Text(text('최신 기록 다시 확인', 'Review latest record')), + ), + if (rule != null) ...[ + ListTile( + key: const Key('recurring-time-date'), + title: Text(recurrenceDate(context, rule.start)), + subtitle: Text(text('날짜 변경', 'Change date')), + onTap: enabled ? () => _pickCivil(true) : null, + ), + TextButton( + key: const Key('recurring-time-clock'), + onPressed: enabled ? () => _pickCivil(false) : null, + child: Text(text('시각 변경', 'Change time')), + ), + ListTile( + key: const Key('recurring-time-zone'), + title: Text(rule.timeZoneId), + subtitle: Text( + text( + '현지 시각을 유지하고 시간대 선택', + 'Choose a zone while preserving wall time', + ), + ), + onTap: enabled ? _pickZone : null, + ), + Text(recurrenceLabel(context, rule)), + TextButton( + key: const Key('recurring-time-rule'), + onPressed: enabled ? _pickRule : null, + child: Text( + text('반복·종료 조건 확인', 'Review recurrence and end condition'), + ), + ), + Text( + text( + '두 번 발생하는 시각의 회차 선택', + 'Occurrence choice when wall time repeats', + ), + ), + Wrap( + spacing: 8, + children: [ + for (final value in RepeatedCivilTime.values) + ChoiceChip( + label: Text( + value == RepeatedCivilTime.first + ? text('첫 번째', 'First') + : text('두 번째', 'Second'), + ), + selected: rule.repeatedTime == value, + onSelected: enabled + ? (_) => setState(() { + final r = _changedRule( + repeated: value, + replaceRepeated: true, + ); + _invalidate(); + _rule = r; + _excluded = {}; + }) + : null, + ), + ], + ), + FilledButton( + key: const Key('build-recurring-time-plan'), + onPressed: enabled && TimeZoneRules.contains(rule.timeZoneId) + ? _buildPlan + : null, + child: Text(text('전체 계획 검토', 'Review complete plan')), + ), + ], + if (review != null) ...[ + RecurringTimeCorrectionPlanSummary( + mapping: review.mapping, + unresolvedOverrides: review.unresolvedOverrides, + confirmedDetachedIds: _detached, + confirmedUnmatchedExclusions: _unmatched, + enabled: enabled, + onDetachedChanged: (v) => setState(() { + _detached = v; + _command = null; + }), + onUnmatchedExclusionsChanged: (v) => setState(() { + _unmatched = v; + _command = null; + }), + onResolveOverride: _resolveOne, + ), + if (conflicts.isNotEmpty) ...[ + Text(text('확정된 충돌을 해결해야 해요', 'Resolve confirmed overlaps')), + for (final conflict in conflicts.skip(offset).take(10)) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${recurrenceDate(context, conflict.slot.civilTime)} · ${review.mapping.rule.timeZoneId}\n${conflict.other.schedule.scheduleName}\n${recurrenceDate(context, conflict.other.schedule.scheduleTime)} · ${conflict.other.schedule.timeZoneId}', + ), + if (conflict.persistent) + Text( + text( + '반복되는 충돌이에요. 규칙을 변경해주세요.', + 'This overlap repeats indefinitely. Change the rule.', + ), + ) + else + TextButton( + onPressed: enabled + ? () => _buildPlan( + excluded: {..._excluded, conflict.slot.key}, + ) + : null, + child: Text( + text( + '새 규칙에서 이 회차 제외·기존 일정은 단발 보존', + 'Exclude this new slot; preserve an existing appointment as standalone', + ), + ), + ), + ], + ), + ), + ), + if (conflicts.length > 10) + Row( + children: [ + TextButton( + onPressed: _conflictPage > 0 + ? () => setState(() => _conflictPage--) + : null, + child: Text(text('이전', 'Previous')), + ), + Text( + '${offset + 1}–${(offset + 10).clamp(0, conflicts.length)} / ${conflicts.length}', + ), + TextButton( + onPressed: offset + 10 < conflicts.length + ? () => setState(() => _conflictPage++) + : null, + child: Text(text('다음', 'Next')), + ), + ], + ), + ], + if (review.mapping.conflictExclusions.isNotEmpty) + Text( + text( + '명시적으로 제외한 새 회차: ${review.mapping.conflictExclusions.length}개. 이 회차도 반복 횟수를 소비합니다.', + 'Explicitly excluded new slots: ${review.mapping.conflictExclusions.length}. These slots still consume the recurrence count.', + ), + ), + if (proof != null && proof.possibleOverlaps.isNotEmpty) ...[ + Text( + text( + '다음 일정은 정확한 충돌 판정이 불가능해요. 해당 일정의 새 시작·알림은 보류되며 개별 교정 때 다시 검사합니다.', + 'Exact overlap is uncertain for these appointments. Their new starts and alarms remain on hold and their individual corrections will recheck overlaps.', + ), + ), + // One acknowledgement covers the exact complete list of identities; + // no per-row widget expansion is needed for a large candidate set. + Text( + '${proof.possibleOverlaps.length} ${text('개 영향 항목', 'possible impacts')}', + ), + for (final impact in possible.skip(possibleOffset).take(10)) + Text( + '${impact.name}\n${impact.originalCivil == null ? '' : recurrenceDate(context, impact.originalCivil!)} · ${impact.timeZoneId ?? ''}\n${_reason(impact.reason)}${impact.rule == null ? '' : '\n${recurrenceLabel(context, impact.rule!)} · ${recurrenceEndLabel(context, impact.rule!)}'}', + ), + if (possible.length > 10) + Row( + children: [ + TextButton( + key: const Key('possible-overlap-previous'), + onPressed: _possiblePage > 0 + ? () => setState(() => _possiblePage--) + : null, + child: Text(text('이전', 'Previous')), + ), + Text( + '${possibleOffset + 1}–${(possibleOffset + 10).clamp(0, possible.length)} / ${possible.length}', + ), + TextButton( + key: const Key('possible-overlap-next'), + onPressed: possibleOffset + 10 < possible.length + ? () => setState(() => _possiblePage++) + : null, + child: Text(text('다음', 'Next')), + ), + ], + ), + CheckboxListTile( + key: const Key('ack-recurring-uncertain'), + value: + _uncertain.length == + proof.possibleOverlaps.map((e) => e.id).toSet().length, + onChanged: enabled + ? (value) => setState(() { + _uncertain = value == true + ? proof.possibleOverlaps.map((e) => e.id).toSet() + : {}; + _command = null; + }) + : null, + title: Text( + text( + '미확정 일정의 실행 보류와 후속 재검증을 확인했어요', + 'I acknowledge withheld execution and later revalidation for uncertain appointments', + ), + ), + ), + ], + FilledButton( + key: const Key('review-recurring-time-confirm'), + onPressed: _canConfirm ? _confirm : null, + child: Text(text('변경 내용 확인', 'Review changes')), + ), + ], + if (_receipt?.deliveryPending ?? false) ...[ + Text( + text( + '저장은 완료됐고 알림 동기화가 남아 있어요.', + 'Saved; notification synchronization is still pending.', + ), + ), + TextButton( + onPressed: _busy ? null : _retryDelivery, + child: Text( + text('알림 동기화 재시도', 'Retry notification synchronization'), + ), + ), + ], + ], + ); + } +} diff --git a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart index 8126fd2d..8bbf6b48 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:on_time_front/domain/entities/schedule_save.dart'; import 'package:on_time_front/domain/repositories/schedule_aggregate_repository.dart'; import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; @@ -16,10 +17,37 @@ import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; import 'package:uuid/uuid.dart'; +import 'package:collection/collection.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'schedule_time_save_review.dart'; part 'schedule_form_event.dart'; part 'schedule_form_state.dart'; +// Transient approval over a frozen projection; never serialized or used as a +// second writer. The review object itself is the UI's exact instance key. +class _RecurrenceReviewAuthority { + const _RecurrenceReviewAuthority({ + required this.review, + required this.submission, + required this.owner, + required this.revision, + required this.editing, + }); + final RecurrenceReview review; + final ScheduleFormSubmission submission; + final Object owner; + final int revision; + final bool editing; +} + +class _AcceptedRecurrenceReview { + const _AcceptedRecurrenceReview(this.authority, this.submission); + final _RecurrenceReviewAuthority authority; + final ScheduleFormSubmission submission; +} + @Injectable() class ScheduleFormBloc extends Bloc { ScheduleFormBloc( @@ -28,7 +56,9 @@ class ScheduleFormBloc extends Bloc { this._updateScheduleFormSubmissionUseCase, { RecurringSchedulesUseCase? recurringSchedules, ScheduleAggregateRepository? aggregate, + @ignoreParam DateTime Function()? now, }) : _aggregate = aggregate, + _now = now ?? DateTime.now, _recurring = recurringSchedules, super(ScheduleFormState()) { on((event, emit) { @@ -53,6 +83,8 @@ class ScheduleFormBloc extends Bloc { on(_onPreparationChanged); on(_onUpdated); on(_onCreated); + on(_onTimeReviewConfirmed); + on(_onRecurrenceReviewConfirmed); on(_onValidated); on( (event, emit) => _saving @@ -67,15 +99,17 @@ class ScheduleFormBloc extends Bloc { ), ), ); - on( - (event, emit) => _saving - ? null - : emit( - state.copyWith( - submissionStatus: ScheduleFormSubmissionStatus.idle, - ), - ), - ); + on((event, emit) { + if (_saving) return; + _pendingTimeReview = null; + _pendingRecurrenceReview = null; + emit( + state.copyWith( + submissionStatus: ScheduleFormSubmissionStatus.idle, + timeReview: null, + ), + ); + }); on((event, emit) async { if (_saving) return; final rule = state.recurrenceRule!; @@ -99,6 +133,254 @@ class ScheduleFormBloc extends Bloc { final RecurringSchedulesUseCase? _recurring; final ScheduleAggregateRepository? _aggregate; + final DateTime Function() _now; + int _draftRevision = 0; + ScheduleTimeSaveReview? _pendingTimeReview; + ScheduleTimeSaveReview? _acceptedTimeReview; + _RecurrenceReviewAuthority? _pendingRecurrenceReview; + _AcceptedRecurrenceReview? _acceptedRecurrenceReview; + + List _reviewInputs(ScheduleFormState value) => [ + value.baseline, + value.mutationId, + value.id, + value.placeId, + value.placeName, + value.scheduleName, + value.scheduleTime, + value.timeZoneId, + value.occurrenceOffsetSeconds, + value.moveTime, + value.scheduleSpareTime, + value.scheduleNote, + value.preparation, + value.originalSchedule, + value.originalPreparation, + value.isChanged, + value.recurrenceRule, + value.recurringScope, + value.recurrenceCountChanged, + ]; + + @override + void onChange(Change change) { + if (!const DeepCollectionEquality().equals( + _reviewInputs(change.currentState), + _reviewInputs(change.nextState), + )) { + _draftRevision++; + _pendingTimeReview = null; + _acceptedTimeReview = null; + _pendingRecurrenceReview = null; + _acceptedRecurrenceReview = null; + } + super.onChange(change); + } + + bool _ownsRecurrenceAuthority(_RecurrenceReviewAuthority authority) => + !isClosed && + _owns(authority.owner) && + _draftRevision == authority.revision && + state.mutationId == authority.submission.mutationId && + state.baseline == authority.submission.baseline; + + bool ownsRecurrenceReview(RecurrenceReview review) { + final pending = _pendingRecurrenceReview; + return pending != null && + identical(pending.review, review) && + _ownsRecurrenceAuthority(pending); + } + + ScheduleFormSubmission _freezeRecurrenceSubmission( + ScheduleFormSubmission value, { + Set excludedSlots = const {}, + String? firstSlot, + bool confirmed = false, + }) => ScheduleFormSubmission( + mutationId: value.mutationId, + baseline: value.baseline, + schedule: value.schedule, + preparation: PreparationEntity( + preparationStepList: List.unmodifiable( + value.preparation.preparationStepList, + ), + ), + preparationChanged: value.preparationChanged, + originalPreparationMode: value.originalPreparationMode, + originalSchedule: value.originalSchedule, + recurrenceRule: value.recurrenceRule, + recurringScope: value.recurringScope, + recurrenceCountChanged: value.recurrenceCountChanged, + excludedSlots: Set.unmodifiable(excludedSlots), + confirmDetached: confirmed, + reviewedFirstSlotKey: firstSlot, + ); + + void _issueRecurrenceReview( + Emitter emit, + RecurrenceReview result, + ScheduleFormSubmission submission, { + required Object owner, + required bool editing, + }) { + if (!_owns(owner) || isClosed) return; + final review = RecurrenceReview( + slots: List.unmodifiable(result.slots), + skipped: List.unmodifiable(result.skipped), + conflicts: List.unmodifiable(result.conflicts), + detached: List.unmodifiable(result.detached), + persistentConflict: result.persistentConflict, + occurrences: Map.unmodifiable(result.occurrences), + totalOccurrences: result.totalOccurrences, + ); + _acceptedRecurrenceReview = null; + _pendingRecurrenceReview = _RecurrenceReviewAuthority( + review: review, + submission: _freezeRecurrenceSubmission(submission), + owner: owner, + revision: _draftRevision, + editing: editing, + ); + emit( + state.copyWith( + submissionStatus: ScheduleFormSubmissionStatus.review, + recurrenceReview: review, + ), + ); + } + + Future _onRecurrenceReviewConfirmed( + ScheduleFormRecurrenceReviewConfirmed event, + Emitter emit, + ) async { + if (_saving) return; + final replay = _acceptedRecurrenceReview; + if (replay != null && + identical(replay.authority.review, event.review) && + _ownsRecurrenceAuthority(replay.authority) && + const SetEquality().equals( + replay.submission.excludedSlots, + event.excludedSlots, + )) { + await _submit( + emit, + editing: replay.authority.editing, + recurrenceSubmission: replay.submission, + ); + return; + } + final pending = _pendingRecurrenceReview; + if (pending == null || !ownsRecurrenceReview(event.review)) return; + final keys = pending.review.slots.map((slot) => slot.key).toSet(); + if (pending.review.persistentConflict || + !keys.containsAll(event.excludedSlots) || + keys.difference(event.excludedSlots).isEmpty) { + return; + } + final submission = _freezeRecurrenceSubmission( + pending.submission, + excludedSlots: event.excludedSlots, + confirmed: true, + firstSlot: pending.review.slots + .firstWhere((slot) => !event.excludedSlots.contains(slot.key)) + .key, + ); + _pendingRecurrenceReview = null; + _acceptedRecurrenceReview = _AcceptedRecurrenceReview(pending, submission); + await _submit( + emit, + editing: pending.editing, + recurrenceSubmission: submission, + ); + } + + bool _needsTimeReview(ScheduleEntity proposed) { + final original = state.originalSchedule; + return original == null || + CivilDateTime.fromFields(original.scheduleTime) != + CivilDateTime.fromFields(proposed.scheduleTime) || + original.timeZoneId != proposed.timeZoneId || + original.occurrenceOffsetSeconds != proposed.occurrenceOffsetSeconds; + } + + bool _ownsTimeReview(ScheduleTimeSaveReview review) => + identical(_pendingTimeReview, review) && + _owns(review.formOwner) && + _draftRevision == review.draftRevision && + state.baseline == review.baseline; + + void _validateAcceptedTimeReview(ScheduleTimeSaveReview review) { + if (!identical(_acceptedTimeReview, review) || + !_owns(review.formOwner) || + _draftRevision != review.draftRevision || + state.baseline != review.baseline) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.conflict); + } + _validateReviewTime(review); + } + + void _validateReviewTime(ScheduleTimeSaveReview review) { + final now = _now().toUtc(); + final current = ScheduleTimeResolver.resolve(review.proposed, nowUtc: now); + final crossedPreparation = + review.preparationStartUtc.isAfter(review.reviewedAtUtc) && + !review.preparationStartUtc.isAfter(now); + if (now.isBefore(review.reviewedAtUtc) || + crossedPreparation || + TimeZoneRules.loadedIdentity != review.rulesIdentity || + current.status != ScheduleTimeResolutionStatus.resolved || + current.instantUtc != review.resolution.instantUtc || + !review.resolution.instantUtc!.isAfter(now)) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.conflict); + } + } + + Future _onTimeReviewConfirmed( + ScheduleFormTimeReviewConfirmed event, + Emitter emit, + ) async { + final review = event.review; + if (_saving || !_ownsTimeReview(review)) return; + try { + if (_aggregate != null) { + final current = await _aggregate.newBaseline(); + if (!_ownsTimeReview(review)) return; + final old = review.baseline; + if (old == null || + old.store != current.store || + old.generation != current.generation || + old.revision != current.revision) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.conflict); + } + } + _validateReviewTime(review); + if (!_ownsTimeReview(review)) return; + _pendingTimeReview = null; + _acceptedTimeReview = review; + await _submit(emit, editing: review.editing, timeReviewed: true); + } on ScheduleSaveRejected catch (error) { + if (!_ownsTimeReview(review)) return; + _pendingTimeReview = null; + emit( + state.copyWith( + submissionStatus: ScheduleFormSubmissionStatus.failure, + saveFailure: error.failure, + timeReview: null, + ), + ); + } catch (_) { + if (!_ownsTimeReview(review)) return; + _pendingTimeReview = null; + emit( + state.copyWith( + submissionStatus: ScheduleFormSubmissionStatus.failure, + saveFailure: ScheduleSaveFailure.unavailable, + timeReview: null, + ), + ); + } + } + Object _formOwner = Object(); final _pendingOwners = {}; Object get formOwner => _formOwner; @@ -166,14 +448,42 @@ class ScheduleFormBloc extends Bloc { await _recurring!.getSegment(original!.recurringSegmentId!); if (!_owns(owner)) return; if (event.scope == RecurringEditScope.following) { - final day = DateTime.parse(original!.recurringSlotKey!); - final start = DateTime( + final day = CivilDateTime.parse( + original!.recurringSlotKey!, + ).toUtcCarrier(); + final start = DateTime.utc( day.year, day.month, day.day, segment.rule.start.hour, segment.rule.start.minute, + segment.rule.start.second, + segment.rule.start.millisecond, + segment.rule.start.microsecond, + ); + final followingRule = segment.rule.withStartAndEnd( + start: start, + count: segment.rule.count, + until: segment.rule.until, ); + int? selectedOffset; + if (TimeZoneRules.contains(followingRule.timeZoneId)) { + try { + // Resolve exactly this original slot day under the base rule. A gap + // must not borrow tomorrow's offset, and a fold uses its saved policy. + final expansion = const RecurrenceEngine().expand( + followingRule, + through: start, + limit: 1, + ); + if (expansion.slots.isNotEmpty && + expansion.slots.first.civilTime == start) { + selectedOffset = expansion.slots.first.offsetSeconds; + } + } on RepeatedTimeChoiceRequired { + // Leave the draft unresolved for the existing explicit choice flow. + } + } emit( state.copyWith( status: ScheduleFormStatus.success, @@ -181,17 +491,15 @@ class ScheduleFormBloc extends Bloc { placeId: segment.schedule.place.id, placeName: segment.schedule.place.placeName, scheduleTime: start, + timeZoneId: segment.rule.timeZoneId, + occurrenceOffsetSeconds: selectedOffset, moveTime: segment.schedule.moveTime, scheduleSpareTime: segment.schedule.scheduleSpareTime, scheduleNote: segment.schedule.scheduleNote, preparation: segment.preparation, originalPreparation: segment.preparation, recurringScope: event.scope, - recurrenceRule: segment.rule.withStartAndEnd( - start: start, - count: segment.rule.count, - until: segment.rule.until, - ), + recurrenceRule: followingRule, ), ); } @@ -234,14 +542,22 @@ class ScheduleFormBloc extends Bloc { if (_saving) return; emit( state.copyWith( - scheduleTime: DateTime( + scheduleTime: DateTime.utc( event.scheduleDate.year, event.scheduleDate.month, event.scheduleDate.day, event.scheduleTime.hour, event.scheduleTime.minute, + event.scheduleTime.second, + event.scheduleTime.millisecond, + event.scheduleTime.microsecond, ), occurrenceOffsetSeconds: event.occurrenceOffsetSeconds, + timeZoneId: event.timeZoneId, + timeZoneExplicitlySelected: event.timeZoneExplicitlySelected, + recurrenceRule: event.timeZoneId == null + ? state.recurrenceRule + : state.recurrenceRule?.withTimeZone(event.timeZoneId!), maxAvailableTime: event.maxAvailableTime, previousScheduleName: event.previousScheduleName, ), @@ -291,27 +607,17 @@ class ScheduleFormBloc extends Bloc { Future _onUpdated( ScheduleFormUpdated event, Emitter emit, - ) => _submit( - emit, - editing: true, - confirmed: event.confirmed, - excludedSlots: event.excludedSlots, - ); + ) => _submit(emit, editing: true); Future _onCreated( ScheduleFormCreated event, Emitter emit, - ) => _submit( - emit, - editing: false, - confirmed: event.confirmed, - excludedSlots: event.excludedSlots, - ); + ) => _submit(emit, editing: false); Future _submit( Emitter emit, { required bool editing, - required bool confirmed, - required Set excludedSlots, + bool timeReviewed = false, + ScheduleFormSubmission? recurrenceSubmission, }) async { if (_saving || state.submissionStatus == ScheduleFormSubmissionStatus.submitting) { @@ -325,21 +631,72 @@ class ScheduleFormBloc extends Bloc { submissionError: null, ), ); + ScheduleFormSubmission? submitted; try { - final value = _submissionFor( - state.createEntity(state), - confirmed: confirmed, - excludedSlots: excludedSlots, - ); - if (value.recurrenceRule != null && !confirmed) { - final review = await _recurring!.review(value); + final accepted = _acceptedRecurrenceReview; + final replay = + accepted != null && + accepted.authority.editing == editing && + _ownsRecurrenceAuthority(accepted.authority) + ? accepted.submission + : null; + final value = + recurrenceSubmission ?? + replay ?? + _submissionFor(state.createEntity(state)); + submitted = value; + final recurrenceApproved = recurrenceSubmission != null || replay != null; + if (value.recurrenceRule == null && + !timeReviewed && + _acceptedTimeReview == null && + state.saveReceipt?.deliveryPending != true && + _needsTimeReview(value.schedule)) { + final now = _now().toUtc(); + final resolution = ScheduleTimeResolver.resolve( + value.schedule, + nowUtc: now, + ); + if (resolution.status != ScheduleTimeResolutionStatus.resolved || + resolution.instantUtc == null || + !resolution.instantUtc!.isAfter(now)) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.invalid); + } + final review = ScheduleTimeSaveReview( + original: state.originalSchedule, + proposed: value.schedule, + resolution: resolution, + baseline: state.baseline, + reviewedAtUtc: now, + preparationStartUtc: resolution.instantUtc!.subtract( + state.totalPreparationTime + + value.schedule.moveTime + + (value.schedule.scheduleSpareTime ?? Duration.zero), + ), + rulesIdentity: TimeZoneRules.loadedIdentity, + formOwner: owner, + draftRevision: _draftRevision, + editing: editing, + ); + _pendingTimeReview = review; + emit( + state.copyWith( + submissionStatus: ScheduleFormSubmissionStatus.timeReview, + timeReview: review, + ), + ); + return; + } + if (value.recurrenceRule != null && !recurrenceApproved) { + final frozen = _freezeRecurrenceSubmission(value); + final review = await _recurring!.review(frozen); if (!_owns(owner)) return; if (review != null) { - emit( - state.copyWith( - submissionStatus: ScheduleFormSubmissionStatus.review, - recurrenceReview: review, - ), + _issueRecurrenceReview( + emit, + review, + frozen, + owner: owner, + editing: editing, ); return; } @@ -366,6 +723,12 @@ class ScheduleFormBloc extends Bloc { ); } on ScheduleSaveRejected catch (e) { if (!_owns(owner)) return; + // The writer rejected/rolled back this command. It needs fresh review. + // Unclassified transport/response loss below retains the accepted intent + // so the same mutation can replay its durable receipt without new data. + _acceptedTimeReview = null; + _acceptedRecurrenceReview = null; + _pendingRecurrenceReview = null; emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.failure, @@ -374,6 +737,8 @@ class ScheduleFormBloc extends Bloc { ); } on RepeatedTimeChoiceRequired catch (e) { if (!_owns(owner)) return; + _acceptedRecurrenceReview = null; + _pendingRecurrenceReview = null; emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.timeChoice, @@ -381,15 +746,22 @@ class ScheduleFormBloc extends Bloc { ), ); } on RecurrenceNeedsReview catch (e) { - if (!_owns(owner)) return; - emit( - state.copyWith( - submissionStatus: ScheduleFormSubmissionStatus.review, - recurrenceReview: e.review, - ), + if (!_owns(owner) || submitted == null) return; + _issueRecurrenceReview( + emit, + e.review, + submitted, + owner: owner, + editing: editing, ); } catch (e) { if (!_owns(owner)) return; + if (e is RecurrenceValidationException || + e is RecurrenceSearchLimit || + e is ArgumentError) { + _acceptedRecurrenceReview = null; + _pendingRecurrenceReview = null; + } emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.failure, @@ -454,11 +826,8 @@ class ScheduleFormBloc extends Bloc { ); } - ScheduleFormSubmission _submissionFor( - ScheduleEntity scheduleEntity, { - bool confirmed = false, - Set excludedSlots = const {}, - }) { + ScheduleFormSubmission _submissionFor(ScheduleEntity scheduleEntity) { + final accepted = _acceptedTimeReview; return ScheduleFormSubmission( schedule: scheduleEntity, preparation: state.preparation!, @@ -476,14 +845,9 @@ class ScheduleFormBloc extends Bloc { ), recurringScope: state.recurringScope, recurrenceCountChanged: state.recurrenceCountChanged, - excludedSlots: excludedSlots, - confirmDetached: confirmed, - reviewedFirstSlotKey: confirmed - ? state.recurrenceReview?.slots - .where((s) => !excludedSlots.contains(s.key)) - .firstOrNull - ?.key - : null, + validateTimeReview: accepted == null + ? null + : () => _validateAcceptedTimeReview(accepted), originalPreparationMode: state.originalPreparationMode, ); } diff --git a/lib/presentation/schedule_create/bloc/schedule_form_event.dart b/lib/presentation/schedule_create/bloc/schedule_form_event.dart index 108ef11d..3725857b 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_event.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_event.dart @@ -46,6 +46,8 @@ final class ScheduleFormScheduleNameChanged extends ScheduleFormEvent { } final class ScheduleFormScheduleDateTimeChanged extends ScheduleFormEvent { + final String? timeZoneId; + final bool timeZoneExplicitlySelected; final DateTime scheduleDate; final DateTime scheduleTime; final int occurrenceOffsetSeconds; @@ -53,6 +55,8 @@ final class ScheduleFormScheduleDateTimeChanged extends ScheduleFormEvent { final String? previousScheduleName; const ScheduleFormScheduleDateTimeChanged({ + this.timeZoneId, + this.timeZoneExplicitlySelected = false, required this.scheduleDate, required this.scheduleTime, required this.occurrenceOffsetSeconds, @@ -62,6 +66,8 @@ final class ScheduleFormScheduleDateTimeChanged extends ScheduleFormEvent { @override List get props => [ + timeZoneId ?? '', + timeZoneExplicitlySelected, scheduleDate, scheduleTime, occurrenceOffsetSeconds, @@ -107,25 +113,26 @@ final class ScheduleFormPreparationChanged extends ScheduleFormEvent { } final class ScheduleFormUpdated extends ScheduleFormEvent { - final bool confirmed; - final Set excludedSlots; - const ScheduleFormUpdated({ - this.confirmed = false, - this.excludedSlots = const {}, - }); + const ScheduleFormUpdated(); @override - List get props => [confirmed, excludedSlots]; + List get props => []; } final class ScheduleFormCreated extends ScheduleFormEvent { - final bool confirmed; + const ScheduleFormCreated(); + @override + List get props => []; +} + +final class ScheduleFormRecurrenceReviewConfirmed extends ScheduleFormEvent { + ScheduleFormRecurrenceReviewConfirmed( + this.review, { + Set excludedSlots = const {}, + }) : excludedSlots = Set.unmodifiable(excludedSlots); + final RecurrenceReview review; final Set excludedSlots; - const ScheduleFormCreated({ - this.confirmed = false, - this.excludedSlots = const {}, - }); @override - List get props => [confirmed, excludedSlots]; + List get props => [review, excludedSlots]; } final class ScheduleFormValidated extends ScheduleFormEvent { @@ -137,6 +144,13 @@ final class ScheduleFormValidated extends ScheduleFormEvent { List get props => [isValid]; } +final class ScheduleFormTimeReviewConfirmed extends ScheduleFormEvent { + const ScheduleFormTimeReviewConfirmed(this.review); + final ScheduleTimeSaveReview review; + @override + List get props => [review]; +} + final class ScheduleFormRecurringChanged extends ScheduleFormEvent { const ScheduleFormRecurringChanged(this.rule, {this.countChanged = false}); final RecurrenceRule? rule; diff --git a/lib/presentation/schedule_create/bloc/schedule_form_state.dart b/lib/presentation/schedule_create/bloc/schedule_form_state.dart index 8d9f12df..f03ff9a0 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_state.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_state.dart @@ -9,6 +9,7 @@ enum ScheduleFormSubmissionStatus { failure, review, timeChoice, + timeReview, deliveryPending, } @@ -30,6 +31,7 @@ final class ScheduleFormState extends Equatable { final String? scheduleName; final DateTime? scheduleTime; final String timeZoneId; + final bool timeZoneExplicitlySelected; final int? occurrenceOffsetSeconds; final Duration? moveTime; final IsPreparationChanged isChanged; @@ -43,6 +45,7 @@ final class ScheduleFormState extends Equatable { final RecurringEditScope recurringScope; final bool recurrenceCountChanged; final RecurrenceReview? recurrenceReview; + final ScheduleTimeSaveReview? timeReview; final DateTime? repeatedTimeDate; final bool isValid; final Duration? maxAvailableTime; @@ -62,6 +65,7 @@ final class ScheduleFormState extends Equatable { this.scheduleName, this.scheduleTime, this.timeZoneId = 'UTC', + this.timeZoneExplicitlySelected = false, this.occurrenceOffsetSeconds, this.moveTime, this.isChanged = IsPreparationChanged.unchanged, @@ -75,6 +79,7 @@ final class ScheduleFormState extends Equatable { this.recurringScope = RecurringEditScope.occurrence, this.recurrenceCountChanged = false, this.recurrenceReview, + this.timeReview, this.repeatedTimeDate, this.isValid = false, this.maxAvailableTime, @@ -96,7 +101,8 @@ final class ScheduleFormState extends Equatable { String? scheduleName, DateTime? scheduleTime, String? timeZoneId, - int? occurrenceOffsetSeconds, + bool? timeZoneExplicitlySelected, + Object? occurrenceOffsetSeconds = _unset, Duration? moveTime, IsPreparationChanged? isChanged, Duration? scheduleSpareTime, @@ -109,6 +115,7 @@ final class ScheduleFormState extends Equatable { RecurringEditScope? recurringScope, bool? recurrenceCountChanged, Object? recurrenceReview = _unset, + Object? timeReview = _unset, DateTime? repeatedTimeDate, bool? isValid, Object? maxAvailableTime = _unset, @@ -132,8 +139,11 @@ final class ScheduleFormState extends Equatable { scheduleName: scheduleName ?? this.scheduleName, scheduleTime: scheduleTime ?? this.scheduleTime, timeZoneId: timeZoneId ?? this.timeZoneId, - occurrenceOffsetSeconds: - occurrenceOffsetSeconds ?? this.occurrenceOffsetSeconds, + timeZoneExplicitlySelected: + timeZoneExplicitlySelected ?? this.timeZoneExplicitlySelected, + occurrenceOffsetSeconds: identical(occurrenceOffsetSeconds, _unset) + ? this.occurrenceOffsetSeconds + : occurrenceOffsetSeconds as int?, moveTime: moveTime ?? this.moveTime, isChanged: isChanged ?? this.isChanged, scheduleSpareTime: scheduleSpareTime ?? this.scheduleSpareTime, @@ -152,6 +162,9 @@ final class ScheduleFormState extends Equatable { recurrenceReview: identical(recurrenceReview, _unset) ? this.recurrenceReview : recurrenceReview as RecurrenceReview?, + timeReview: identical(timeReview, _unset) + ? this.timeReview + : timeReview as ScheduleTimeSaveReview?, repeatedTimeDate: repeatedTimeDate ?? this.repeatedTimeDate, isValid: isValid ?? this.isValid, maxAvailableTime: identical(maxAvailableTime, _unset) @@ -176,9 +189,7 @@ final class ScheduleFormState extends Equatable { scheduleName: state.scheduleName!, scheduleTime: state.scheduleTime!, timeZoneId: state.timeZoneId, - occurrenceOffsetSeconds: - state.occurrenceOffsetSeconds ?? - state.scheduleTime!.timeZoneOffset.inSeconds, + occurrenceOffsetSeconds: state.occurrenceOffsetSeconds, moveTime: state.moveTime!, isChanged: !(state.isChanged == IsPreparationChanged.unchanged), scheduleSpareTime: state.scheduleSpareTime, @@ -217,6 +228,7 @@ final class ScheduleFormState extends Equatable { scheduleName, scheduleTime, timeZoneId, + timeZoneExplicitlySelected, occurrenceOffsetSeconds, moveTime, isChanged, @@ -230,6 +242,7 @@ final class ScheduleFormState extends Equatable { recurringScope, recurrenceCountChanged, recurrenceReview, + timeReview, repeatedTimeDate, isValid, maxAvailableTime, diff --git a/lib/presentation/schedule_create/bloc/schedule_time_save_review.dart b/lib/presentation/schedule_create/bloc/schedule_time_save_review.dart new file mode 100644 index 00000000..019e7d68 --- /dev/null +++ b/lib/presentation/schedule_create/bloc/schedule_time_save_review.dart @@ -0,0 +1,29 @@ +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; + +/// Ephemeral confirmation of an exact form projection, never a new writer. +final class ScheduleTimeSaveReview { + const ScheduleTimeSaveReview({ + required this.original, + required this.proposed, + required this.resolution, + required this.baseline, + required this.reviewedAtUtc, + required this.preparationStartUtc, + required this.rulesIdentity, + required this.formOwner, + required this.draftRevision, + required this.editing, + }); + final ScheduleEntity? original; + final ScheduleEntity proposed; + final ScheduleTimeResolution resolution; + final ScheduleEditBaseline? baseline; + final DateTime reviewedAtUtc; + final DateTime preparationStartUtc; + final String rulesIdentity; + final Object formOwner; + final int draftRevision; + final bool editing; +} diff --git a/lib/presentation/schedule_create/components/schedule_multi_page_form.dart b/lib/presentation/schedule_create/components/schedule_multi_page_form.dart index 79e6e92a..e23d84d7 100644 --- a/lib/presentation/schedule_create/components/schedule_multi_page_form.dart +++ b/lib/presentation/schedule_create/components/schedule_multi_page_form.dart @@ -19,6 +19,7 @@ import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_pr import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart'; import 'package:on_time_front/presentation/shared/components/step_progress.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart'; class ScheduleMultiPageForm extends StatefulWidget { const ScheduleMultiPageForm({super.key, this.onSaved}); @@ -75,7 +76,21 @@ class _ScheduleMultiPageFormState extends State bloc.state.mutationId == mutation && (ModalRoute.of(context)?.isCurrent ?? false); if (!context.mounted || !ownsCurrentForm()) return; - if (state.submissionStatus == ScheduleFormSubmissionStatus.review) { + if (state.submissionStatus == ScheduleFormSubmissionStatus.timeReview) { + final review = state.timeReview; + if (review == null) return; + final accepted = await showScheduleTimeSaveReview( + context: context, + review: review, + ); + if (!ownsCurrentForm()) return; + if (accepted == true) { + bloc.add(ScheduleFormTimeReviewConfirmed(review)); + } else { + bloc.add(const ScheduleFormReviewDismissed()); + } + } else if (state.submissionStatus == + ScheduleFormSubmissionStatus.review) { final selected = await showModalBottomSheet>( context: context, isScrollControlled: true, @@ -88,16 +103,19 @@ class _ScheduleMultiPageFormState extends State ), ), ); - if (!context.mounted || !ownsCurrentForm()) return; + if (!context.mounted || + !ownsCurrentForm() || + !bloc.ownsRecurrenceReview(state.recurrenceReview!)) { + return; + } if (selected == null) { bloc.add(const ScheduleFormReviewDismissed()); - } else if (state.originalSchedule == null) { - bloc.add( - ScheduleFormCreated(confirmed: true, excludedSlots: selected), - ); } else { bloc.add( - ScheduleFormUpdated(confirmed: true, excludedSlots: selected), + ScheduleFormRecurrenceReviewConfirmed( + state.recurrenceReview!, + excludedSlots: selected, + ), ); } } else if (state.submissionStatus == diff --git a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart index 36bfe3e3..130c2adb 100644 --- a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart +++ b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -30,7 +32,22 @@ class ScheduleDateTimeCubit extends Cubit { final GetAdjacentSchedulesWithPreparationUseCase _getNextScheduleWithPreparationUseCase; + int _selectionVersion = 0; + + bool Function() _captureSelection({bool advance = false}) { + if (advance) _selectionVersion++; + final revision = _selectionVersion; + final owner = scheduleFormBloc.formOwner; + final mutation = scheduleFormBloc.state.mutationId; + return () => + !isClosed && + revision == _selectionVersion && + scheduleFormBloc.ownsForm(owner) && + scheduleFormBloc.state.mutationId == mutation; + } + void initialize() { + final current = _captureSelection(advance: true); final scheduleDateTimeState = ScheduleDateTimeState.fromScheduleFormState( scheduleFormBloc.state, ); @@ -40,6 +57,8 @@ class ScheduleDateTimeCubit extends Cubit { scheduleDate: scheduleDateTimeState.scheduleDate, scheduleTime: scheduleDateTimeState.scheduleTime, timeZoneId: scheduleDateTimeState.timeZoneId, + timeZoneExplicitlySelected: + scheduleDateTimeState.timeZoneExplicitlySelected, selectedOccurrenceOffsetSeconds: scheduleDateTimeState.selectedOccurrenceOffsetSeconds, ), @@ -51,15 +70,18 @@ class ScheduleDateTimeCubit extends Cubit { scheduleDateTimeState.scheduleTime.isValid) { scheduleFormBloc.add(ScheduleFormValidated(isValid: false)); // Load adjacent schedules first, then check overlap - _loadAdjacentSchedules( - scheduleDateTimeState.scheduleDate.value!, - ).then((_) => checkScheduleOverlap()); + _loadAdjacentSchedules(scheduleDateTimeState.scheduleDate.value!).then(( + _, + ) { + if (current()) checkScheduleOverlap(); + }); } else { scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } } Future scheduleDateChanged(DateTime scheduleDate) async { + final current = _captureSelection(advance: true); final ScheduleDateInputModel scheduleDateInputModel = ScheduleDateInputModel.dirty(scheduleDate); emit( @@ -76,16 +98,19 @@ class ScheduleDateTimeCubit extends Cubit { if (scheduleDateInputModel.isValid) { scheduleFormBloc.add(ScheduleFormValidated(isValid: false)); await _loadAdjacentSchedules(scheduleDate); + if (!current()) return; } // Check for schedule overlap if time is already set if (scheduleDateInputModel.isValid && state.scheduleTime.isValid) { await checkScheduleOverlap(); + if (!current()) return; } scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } Future scheduleTimeChanged(DateTime scheduleTime) async { + final current = _captureSelection(advance: true); final ScheduleTimeInputModel scheduleTimeInputModel = ScheduleTimeInputModel.dirty(scheduleTime); emit( @@ -102,11 +127,13 @@ class ScheduleDateTimeCubit extends Cubit { if (state.scheduleDate.isValid && scheduleTimeInputModel.isValid) { scheduleFormBloc.add(ScheduleFormValidated(isValid: false)); await checkScheduleOverlap(); + if (!current()) return; } scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } void setRecurring(bool enabled) { + _selectionVersion++; emit( state.copyWith( isRecurring: enabled, @@ -126,13 +153,34 @@ class ScheduleDateTimeCubit extends Cubit { void occurrenceOffsetSelected(int offsetSeconds) { if (!state.occurrenceOffsetOptions.contains(offsetSeconds)) return; + _selectionVersion++; emit(state.copyWith(selectedOccurrenceOffsetSeconds: offsetSeconds)); + checkScheduleOverlap(); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } + Future timeZoneSelected(String zone) async { + if (!TimeZoneRules.contains(zone)) return; + final current = _captureSelection(advance: true); + emit( + state.copyWith( + timeZoneId: zone, + timeZoneExplicitlySelected: true, + selectedOccurrenceOffsetSeconds: null, + civilTimeResolved: false, + occurrenceOffsetOptions: const [], + clearOverlap: true, + clearPreviousOverlap: true, + ), + ); + _resolveCivilTime(); + await checkScheduleOverlap(); + if (current()) validateCurrentSelection(); + } + void _resolveCivilTime() { final selected = state.selectedScheduleDateTime; - if (selected == null) { + if (selected == null || !TimeZoneRules.contains(state.timeZoneId)) { emit( state.copyWith( civilTimeResolved: false, @@ -148,9 +196,9 @@ class ScheduleDateTimeCubit extends Cubit { state.timeZoneId, ).map((occurrence) => occurrence.offsetSeconds).toList(); final existing = state.selectedOccurrenceOffsetSeconds; - final selectedOffset = options.length == 1 - ? options.single - : (options.contains(existing) ? existing : null); + final selectedOffset = options.contains(existing) + ? existing + : (existing == null && options.length == 1 ? options.single : null); emit( state.copyWith( civilTimeResolved: true, @@ -179,6 +227,7 @@ class ScheduleDateTimeCubit extends Cubit { } Future checkScheduleOverlap() async { + final current = _captureSelection(); if (state.isRecurring) { emit(state.copyWith(clearOverlap: true, clearPreviousOverlap: true)); validateCurrentSelection(); @@ -194,14 +243,14 @@ class ScheduleDateTimeCubit extends Cubit { try { // Combine date and time final selectedDate = state.scheduleDate.value!; - final selectedTime = state.scheduleTime.value!; - final selectedDateTime = DateTime( - selectedDate.year, - selectedDate.month, - selectedDate.day, - selectedTime.hour, - selectedTime.minute, - ); + final offset = state.selectedOccurrenceOffsetSeconds; + if (offset == null) { + validateCurrentSelection(); + return; + } + final selectedDateTime = CivilDateTime.fromFields( + state.selectedScheduleDateTime!, + ).atOffset(offset); // Get the current schedule form state to check if editing final formState = scheduleFormBloc.state; @@ -224,6 +273,7 @@ class ScheduleDateTimeCubit extends Cubit { startDate: startDate, endDate: endDate, ); + if (!current()) return; AppLogger.debug( 'Previous schedule found: ${adjacentSchedules.hasPrevious}, Next schedule found: ${adjacentSchedules.hasNext}', @@ -285,7 +335,7 @@ class ScheduleDateTimeCubit extends Cubit { // Calculate when previous schedule ends // Previous schedule ends at: scheduleTime (since preparation is before schedule time) - final previousScheduleEndTime = previousSchedule.scheduleTime; + final previousScheduleEndTime = previousSchedule.occurrenceInstantUtc; // Calculate time difference // If negative, selected time is before previous schedule ends (overlapping) @@ -364,7 +414,8 @@ class ScheduleDateTimeCubit extends Cubit { emit(state.copyWith(clearPreviousOverlap: true)); } } catch (e) { - // On error, clear both overlaps + if (!current()) return; + // Only the current selection can change its overlap state. AppLogger.debug('Error checking schedule overlap: $e'); emit(state.copyWith(clearOverlap: true, clearPreviousOverlap: true)); } @@ -383,6 +434,8 @@ class ScheduleDateTimeCubit extends Cubit { scheduleFormBloc.add( ScheduleFormScheduleDateTimeChanged( + timeZoneId: state.timeZoneId, + timeZoneExplicitlySelected: state.timeZoneExplicitlySelected, scheduleDate: state.scheduleDate.value!, scheduleTime: state.scheduleTime.value!, occurrenceOffsetSeconds: state.selectedOccurrenceOffsetSeconds ?? 0, @@ -402,7 +455,7 @@ class ScheduleDateTimeCubit extends Cubit { } ({DateTime startDate, DateTime endDate}) _getDateRange(DateTime date) { - final baseDate = DateTime(date.year, date.month, date.day); + final baseDate = DateTime.utc(date.year, date.month, date.day); return ( startDate: baseDate.subtract(const Duration(days: 1)), endDate: baseDate.add(const Duration(days: 2)), diff --git a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart index 0f63a6bc..f74f2c2c 100644 --- a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart +++ b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart @@ -13,6 +13,7 @@ class ScheduleDateTimeState extends Equatable { this.previousOverlapDuration, this.previousScheduleName, this.timeZoneId = 'UTC', + this.timeZoneExplicitlySelected = false, this.civilTimeResolved = false, this.occurrenceOffsetOptions = const [], this.selectedOccurrenceOffsetSeconds, @@ -27,6 +28,7 @@ class ScheduleDateTimeState extends Equatable { final Duration? previousOverlapDuration; final String? previousScheduleName; final String timeZoneId; + final bool timeZoneExplicitlySelected; final bool civilTimeResolved; final List occurrenceOffsetOptions; final int? selectedOccurrenceOffsetSeconds; @@ -36,13 +38,14 @@ class ScheduleDateTimeState extends Equatable { bool get requiresOccurrenceChoice => civilTimeResolved && - occurrenceOffsetOptions.length > 1 && + occurrenceOffsetOptions.isNotEmpty && selectedOccurrenceOffsetSeconds == null; bool get hasAmbiguousCivilTime => civilTimeResolved && occurrenceOffsetOptions.length > 1; bool get isValid => + TimeZoneRules.contains(timeZoneId) && Formz.validate([scheduleDate, scheduleTime]) && (isRecurring || (!isOverlapping && @@ -57,29 +60,27 @@ class ScheduleDateTimeState extends Equatable { } final selectedDate = scheduleDate.value!; final selectedTime = scheduleTime.value!; - return DateTime( + return DateTime.utc( selectedDate.year, selectedDate.month, selectedDate.day, selectedTime.hour, selectedTime.minute, + selectedTime.second, + selectedTime.millisecond, + selectedTime.microsecond, ); } - bool get isPastScheduleTime { - final selectedDateTime = selectedScheduleDateTime; - if (selectedDateTime == null) { - return false; - } + bool get isPastScheduleTime => isPastScheduleTimeAt(DateTime.now()); + + bool isPastScheduleTimeAt(DateTime now) { + final selected = selectedScheduleDateTime; final offset = selectedOccurrenceOffsetSeconds; - if (offset == null) return selectedDateTime.isBefore(DateTime.now()); - return DateTime.utc( - selectedDateTime.year, - selectedDateTime.month, - selectedDateTime.day, - selectedDateTime.hour, - selectedDateTime.minute, - ).subtract(Duration(seconds: offset)).isBefore(DateTime.now().toUtc()); + if (selected == null || offset == null) return false; + return CivilDateTime.fromFields( + selected, + ).atOffset(offset).isBefore(now.toUtc()); } /// Returns true if there's an overlap warning or error to display (for next schedule) @@ -144,6 +145,7 @@ class ScheduleDateTimeState extends Equatable { bool clearOverlap = false, bool clearPreviousOverlap = false, String? timeZoneId, + bool? timeZoneExplicitlySelected, bool? civilTimeResolved, List? occurrenceOffsetOptions, Object? selectedOccurrenceOffsetSeconds = _unset, @@ -168,6 +170,8 @@ class ScheduleDateTimeState extends Equatable { ? null : (previousScheduleName ?? this.previousScheduleName), timeZoneId: timeZoneId ?? this.timeZoneId, + timeZoneExplicitlySelected: + timeZoneExplicitlySelected ?? this.timeZoneExplicitlySelected, civilTimeResolved: civilTimeResolved ?? this.civilTimeResolved, occurrenceOffsetOptions: occurrenceOffsetOptions ?? this.occurrenceOffsetOptions, @@ -184,6 +188,7 @@ class ScheduleDateTimeState extends Equatable { scheduleDate: ScheduleDateInputModel.pure(state.scheduleTime), scheduleTime: ScheduleTimeInputModel.pure(state.scheduleTime), timeZoneId: state.timeZoneId, + timeZoneExplicitlySelected: state.timeZoneExplicitlySelected, selectedOccurrenceOffsetSeconds: state.occurrenceOffsetSeconds, ); } @@ -195,10 +200,11 @@ class ScheduleDateTimeState extends Equatable { isOverlapping, isRecurring, nextScheduleName ?? '', - nextPreparationStartTime ?? DateTime(0), + nextPreparationStartTime ?? DateTime.utc(0), previousOverlapDuration ?? const Duration(), previousScheduleName ?? '', timeZoneId, + timeZoneExplicitlySelected, civilTimeResolved, occurrenceOffsetOptions, selectedOccurrenceOffsetSeconds ?? 0, diff --git a/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart b/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart index 8030434b..87962f20 100644 --- a/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart +++ b/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart @@ -1,15 +1,19 @@ +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/recurring/recurrence_settings_sheet.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/core/time/civil_time_resolver.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; -import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; +import 'schedule_time_zone_picker.dart'; import 'package:on_time_front/presentation/schedule_create/components/message_bubble.dart'; //TODO: Format DateTime string @@ -82,22 +86,21 @@ class ScheduleDateTimeForm extends StatelessWidget { ? null : _localizedDateString(context, state.scheduleDate.value!), ), - onTap: () { - context.showCupertinoDatePickerModal( + onTap: () async { + final cubit = context.read(); + final owns = _captureFormOwner(context, cubit); + final value = await showCivilDateTimePicker( + context: context, title: AppLocalizations.of(context)!.enterDate, - mode: CupertinoDatePickerMode.date, - initialValue: state.scheduleDate.value ?? DateTime.now(), - onDisposed: () { - context - .read() - .validateCurrentSelection(); - }, - onSaved: (DateTime newDateTime) { - context.read().scheduleDateChanged( - newDateTime, - ); - }, + dateOnly: true, + initialCivil: + state.selectedScheduleDateTime ?? + state.scheduleDate.value ?? + DateTime.now(), ); + if (!owns()) return; + if (value != null) await cubit.scheduleDateChanged(value); + if (owns()) cubit.validateCurrentSelection(); }, ), const SizedBox(height: 8), @@ -125,36 +128,84 @@ class ScheduleDateTimeForm extends StatelessWidget { prefixIcon: const Icon(Icons.schedule, size: 24), suffixIcon: const Icon(Icons.chevron_right, size: 20), labelText: recurrenceText(context, '시간', 'Time'), - hintText: DateFormat.jm( - Localizations.localeOf(context).toString(), - ).format(DateTime.now()), + hintText: TimeOfDay.now().format(context), hintStyle: fadedHintStyle, ), controller: TextEditingController( text: state.scheduleTime.value == null ? null - : DateFormat.jm( - Localizations.localeOf(context).toString(), - ).format(state.scheduleTime.value!), + : TimeOfDay.fromDateTime( + state.scheduleTime.value!, + ).format(context), ), - onTap: () { - context.showCupertinoDatePickerModal( + onTap: () async { + final cubit = context.read(); + final owns = _captureFormOwner(context, cubit); + final value = await showCivilDateTimePicker( + context: context, title: AppLocalizations.of(context)!.enterTime, - mode: CupertinoDatePickerMode.time, - initialValue: state.scheduleTime.value ?? DateTime.now(), - onDisposed: () { - context - .read() - .validateCurrentSelection(); - }, - onSaved: (DateTime newDateTime) { - context.read().scheduleTimeChanged( - newDateTime, - ); - }, + dateOnly: false, + initialCivil: + state.selectedScheduleDateTime ?? + state.scheduleTime.value ?? + DateTime.now(), ); + if (!owns()) return; + if (value != null) await cubit.scheduleTimeChanged(value); + if (owns()) cubit.validateCurrentSelection(); }, ), + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.public), + title: Text( + state.timeZoneId.isEmpty + ? AppLocalizations.of(context)!.zonedTimeChooseZone + : state.timeZoneId, + ), + subtitle: Text( + state.timeZoneExplicitlySelected + ? AppLocalizations.of(context)!.zonedTimeSelectedZone + : form.originalSchedule != null + ? AppLocalizations.of(context)!.zonedTimeSavedZone + : state.timeZoneId.isEmpty + ? AppLocalizations.of( + context, + )!.zonedTimeSelectUnavailableZone + : AppLocalizations.of(context)!.zonedTimeDetectedZone, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final cubit = context.read(); + final owns = _captureFormOwner(context, cubit); + final zone = await showScheduleTimeZonePicker( + context: context, + currentZone: state.timeZoneId, + civil: state.selectedScheduleDateTime, + offsetSeconds: state.selectedOccurrenceOffsetSeconds, + isCurrent: owns, + ); + if (zone != null && owns()) { + await cubit.timeZoneSelected(zone); + } + }, + ), + if (state.selectedScheduleDateTime != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ScheduleZonedTime( + civil: CivilDateTime.fromFields( + state.selectedScheduleDateTime!, + ), + timeZoneId: state.timeZoneId, + resolution: _selectionResolution(state), + showResolutionStatus: + state.isRecurring || + !(state.isNonexistentCivilTime || + state.hasAmbiguousCivilTime || + state.requiresOccurrenceChoice), + ), + ), const SizedBox(height: 24), if (form.originalSchedule == null || form.recurrenceRule != null) RecurrenceValue( @@ -168,6 +219,7 @@ class ScheduleDateTimeForm extends StatelessWidget { : () async { final bloc = context.read(); final cubit = context.read(); + final owns = _captureFormOwner(context, cubit); final selected = await showModalBottomSheet< RecurrenceSettingsResult @@ -179,7 +231,7 @@ class ScheduleDateTimeForm extends StatelessWidget { height: MediaQuery.sizeOf(context).height * .94, child: RecurrenceSettingsSheet( start: state.selectedScheduleDateTime!, - timeZoneId: form.timeZoneId, + timeZoneId: state.timeZoneId, initial: form.recurrenceRule, allowNone: form.originalSchedule == null, leadTime: @@ -189,9 +241,7 @@ class ScheduleDateTimeForm extends StatelessWidget { ), ), ); - if (selected != null && - !bloc.isClosed && - !cubit.isClosed) { + if (selected != null && owns()) { bloc.add( ScheduleFormRecurringChanged( selected.rule, @@ -263,14 +313,72 @@ class ScheduleDateTimeForm extends StatelessWidget { type: MessageBubbleType.error, ), ), - if (!state.isRecurring && state.hasAmbiguousCivilTime) + if (!state.isRecurring && state.isNonexistentCivilTime) + TextButton( + onPressed: () async { + final cubit = context.read(); + final owns = _captureFormOwner(context, cubit); + final civil = state.selectedScheduleDateTime; + if (civil == null) return; + final suggested = CivilTimeResolver.nextValidCivilTime( + civil, + state.timeZoneId, + ); + if (suggested == null) return; + final occurrences = CivilTimeResolver.resolve( + suggested, + state.timeZoneId, + ); + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text( + AppLocalizations.of(context)!.zonedTimeNextValid, + ), + content: ScheduleZonedTime( + civil: CivilDateTime.fromFields(suggested), + timeZoneId: state.timeZoneId, + resolution: ScheduleTimeResolution( + status: occurrences.length == 1 + ? ScheduleTimeResolutionStatus.resolved + : ScheduleTimeResolutionStatus.ambiguous, + instantUtc: occurrences.length == 1 + ? occurrences.single.instantUtc + : null, + occurrences: occurrences, + ), + showInstant: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(AppLocalizations.of(context)!.ok), + ), + ], + ), + ); + if (accepted != true || !owns()) return; + await cubit.scheduleDateChanged(suggested); + if (owns()) await cubit.scheduleTimeChanged(suggested); + }, + child: Text(AppLocalizations.of(context)!.zonedTimeNextValid), + ), + if (!state.isRecurring && + (state.hasAmbiguousCivilTime || state.requiresOccurrenceChoice)) Padding( padding: const EdgeInsets.only(top: 12.0, left: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _dstOverlapMessage(context), + state.hasAmbiguousCivilTime + ? _dstOverlapMessage(context) + : AppLocalizations.of(context)!.zonedTimeRulesChanged, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), @@ -296,6 +404,22 @@ class ScheduleDateTimeForm extends StatelessWidget { .occurrenceOffsetSelected( state.occurrenceOffsetOptions[index], ), + child: ScheduleZonedTime( + showInstant: true, + civil: CivilDateTime.fromFields( + state.selectedScheduleDateTime!, + ), + timeZoneId: state.timeZoneId, + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: + CivilDateTime.fromFields( + state.selectedScheduleDateTime!, + ).atOffset( + state.occurrenceOffsetOptions[index], + ), + ), + ), ), ], ), @@ -317,22 +441,17 @@ class ScheduleDateTimeForm extends StatelessWidget { } } -String _dstGapMessage(BuildContext context) { - return Localizations.localeOf(context).languageCode == 'ko' - ? '일광절약시간 변경으로 존재하지 않는 시각이에요. 다른 시간을 선택해주세요.' - : 'This time does not exist because of a daylight-saving change. Choose another time.'; -} +String _dstGapMessage(BuildContext context) => + AppLocalizations.of(context)!.zonedTimeNonexistent; -String _dstOverlapMessage(BuildContext context) { - return Localizations.localeOf(context).languageCode == 'ko' - ? '이 시각은 두 번 발생해요. 사용할 시각을 선택해주세요.' - : 'This time occurs twice. Choose which occurrence to use.'; -} +String _dstOverlapMessage(BuildContext context) => + AppLocalizations.of(context)!.zonedTimeAmbiguous; String _occurrenceLabel(BuildContext context, int index, int offsetSeconds) { - final occurrence = Localizations.localeOf(context).languageCode == 'ko' - ? (index == 0 ? '첫 번째' : '두 번째') - : (index == 0 ? 'First' : 'Second'); + final l = AppLocalizations.of(context)!; + final occurrence = index == 0 + ? l.zonedTimeFirstOccurrence + : l.zonedTimeSecondOccurrence; return '$occurrence (${CivilTimeResolver.formatUtcOffset(offsetSeconds)})'; } @@ -347,3 +466,41 @@ String _localizedDateString(BuildContext context, DateTime date) { ).format(date); } } + +bool Function() _captureFormOwner( + BuildContext context, + ScheduleDateTimeCubit cubit, +) { + final bloc = cubit.scheduleFormBloc; + final owner = bloc.formOwner; + final mutation = bloc.state.mutationId; + return () => + context.mounted && + !cubit.isClosed && + bloc.ownsForm(owner) && + bloc.state.mutationId == mutation; +} + +ScheduleTimeResolution _selectionResolution(ScheduleDateTimeState state) { + final civil = state.selectedScheduleDateTime; + if (civil == null || !TimeZoneRules.contains(state.timeZoneId)) { + return ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.unknownZone, + ); + } + final candidates = CivilTimeResolver.resolve(civil, state.timeZoneId); + final selected = candidates + .where((c) => c.offsetSeconds == state.selectedOccurrenceOffsetSeconds) + .firstOrNull; + return ScheduleTimeResolution( + status: candidates.isEmpty + ? ScheduleTimeResolutionStatus.nonexistent + : selected != null + ? ScheduleTimeResolutionStatus.resolved + : candidates.length > 1 + ? ScheduleTimeResolutionStatus.ambiguous + : ScheduleTimeResolutionStatus.changed, + instantUtc: selected?.instantUtc, + occurrences: candidates, + ); +} diff --git a/lib/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart b/lib/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart new file mode 100644 index 00000000..3f0b42cd --- /dev/null +++ b/lib/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_state.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; +import 'package:on_time_front/presentation/shared/time/time_zone_catalog.dart'; + +Future showScheduleTimeZonePicker({ + required BuildContext context, + required String currentZone, + required DateTime? civil, + required int? offsetSeconds, + required bool Function() isCurrent, +}) async { + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => + _TimeZonePicker(currentZone: currentZone, civil: civil), + ); + return context.mounted && isCurrent() ? selected : null; +} + +class _TimeZonePicker extends StatefulWidget { + const _TimeZonePicker({required this.currentZone, required this.civil}); + final String currentZone; + final DateTime? civil; + @override + State<_TimeZonePicker> createState() => _TimeZonePickerState(); +} + +class _TimeZonePickerState extends State<_TimeZonePicker> { + final _search = TextEditingController(); + String? _candidate; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + Widget _choice(String zone, {String? label}) { + final l = AppLocalizations.of(context)!; + return ListTile( + title: Text('${TimeZoneCatalog.cityName(zone, l.localeName)} · $zone'), + subtitle: label == null ? null : Text(label), + selected: _candidate == zone, + trailing: _candidate == zone ? const Icon(Icons.check) : null, + onTap: () => setState(() => _candidate = zone), + ); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final device = DeviceTimeZoneScope.stateOf(context); + final matches = TimeZoneCatalog.search(_search.text); + final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + final zone = _candidate; + final civil = widget.civil; + final candidates = zone == null || civil == null + ? const [] + : CivilTimeResolver.resolve(civil, zone); + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SizedBox( + height: + (MediaQuery.sizeOf(context).height * .9 - + MediaQuery.viewInsetsOf(context).bottom) + .clamp(0.0, double.infinity), + child: SafeArea( + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + l.zonedTimeChooseZone, + style: Theme.of(context).textTheme.titleLarge, + ), + TextField( + key: const ValueKey('time-zone-search'), + controller: _search, + decoration: InputDecoration( + labelText: l.zonedTimeSearch, + prefixIcon: const Icon(Icons.search), + suffixIcon: _search.text.isEmpty + ? null + : IconButton( + tooltip: l.zonedTimeClearSearch, + icon: const Icon(Icons.clear), + onPressed: () => setState(_search.clear), + ), + ), + onChanged: (_) => setState(() {}), + ), + if (device.status == DeviceTimeZoneStatus.known) + _choice( + device.identifier!, + label: l.zonedTimeCurrentDeviceZone, + ) + else + Text( + device.status == DeviceTimeZoneStatus.loading + ? l.zonedTimeDeviceLoading + : l.zonedTimeDeviceUnavailable, + ), + if (TimeZoneRules.contains(widget.currentZone)) + _choice( + widget.currentZone, + label: l.zonedTimeCurrentSelection, + ) + else if (widget.currentZone.isNotEmpty) + Text( + '${l.zonedTimeCurrentSelection}: ${widget.currentZone}\n${l.zonedTimeUnknownZone}', + ), + if (zone != null) ...[ + const Divider(), + Text( + l.zonedTimeDraftPreview, + style: Theme.of(context).textTheme.titleMedium, + ), + Text(l.zonedTimeKeepCivil), + if (civil == null) + Text(zone) + else + ScheduleZonedTime( + civil: CivilDateTime.fromFields(civil), + timeZoneId: zone, + resolution: ScheduleTimeResolution( + status: candidates.isEmpty + ? ScheduleTimeResolutionStatus.nonexistent + : candidates.length == 1 + ? ScheduleTimeResolutionStatus.resolved + : ScheduleTimeResolutionStatus.ambiguous, + instantUtc: candidates.length == 1 + ? candidates.single.instantUtc + : null, + occurrences: candidates, + ), + showInstant: true, + ), + ], + const Divider(), + if (matches.isEmpty) Text(l.zonedTimeNoMatches), + ...matches.map((zone) => _choice(zone)), + ], + ), + ), + if (keyboardOpen) + Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: l.cancel, + constraints: const BoxConstraints( + minWidth: 48, + minHeight: 48, + ), + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + const SizedBox(width: 12), + IconButton.filled( + key: const ValueKey('time-zone-apply'), + tooltip: l.zonedTimeApplyDraft, + constraints: const BoxConstraints( + minWidth: 48, + minHeight: 48, + ), + onPressed: _candidate == null + ? null + : () => Navigator.pop(context, _candidate), + icon: const Icon(Icons.check), + ), + ], + ), + ) + else + Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 12, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l.cancel), + ), + FilledButton( + key: const ValueKey('time-zone-apply'), + onPressed: _candidate == null + ? null + : () => Navigator.pop(context, _candidate), + child: Text(l.zonedTimeApplyDraft), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/schedule_create/screens/schedule_edit_screen.dart b/lib/presentation/schedule_create/screens/schedule_edit_screen.dart index da0f580c..a3314811 100644 --- a/lib/presentation/schedule_create/screens/schedule_edit_screen.dart +++ b/lib/presentation/schedule_create/screens/schedule_edit_screen.dart @@ -1,4 +1,9 @@ import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/use-cases/schedule_time_correction_workflow.dart'; +import 'schedule_time_correction_screen.dart'; +import 'package:on_time_front/domain/use-cases/recurring_time_correction_workflow.dart'; +import 'package:on_time_front/presentation/recurring/recurring_time_correction_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/core/di/di_setup.dart'; @@ -26,6 +31,36 @@ class ScheduleEditScreen extends StatelessWidget { ), child: BlocBuilder( builder: (context, state) { + final original = state.originalSchedule; + if (state.status == ScheduleFormStatus.success && + original != null && + ScheduleTimeResolver.resolve( + original, + nowUtc: DateTime.now().toUtc(), + ).instantUtc == + null) { + if (scope == RecurringEditScope.following && + original.isRecurring) { + return Material( + type: MaterialType.transparency, + child: RecurringTimeCorrectionScreen( + key: ValueKey((original.id, state.mutationId)), + scheduleId: original.id, + workflow: getIt.get(), + individualWorkflow: getIt + .get(), + ), + ); + } + return Material( + type: MaterialType.transparency, + child: ScheduleTimeCorrectionScreen( + key: ValueKey((original.id, state.mutationId)), + scheduleId: original.id, + workflow: getIt.get(), + ), + ); + } return ScheduleMultiPageForm( onSaved: () => context.read().add( const ScheduleFormUpdated(), diff --git a/lib/presentation/schedule_create/screens/schedule_time_correction_screen.dart b/lib/presentation/schedule_create/screens/schedule_time_correction_screen.dart new file mode 100644 index 00000000..43c1c7f3 --- /dev/null +++ b/lib/presentation/schedule_create/screens/schedule_time_correction_screen.dart @@ -0,0 +1,569 @@ +import 'package:collection/collection.dart'; +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/use-cases/schedule_time_correction_workflow.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; + +/// A dedicated review does not expose normal aggregate editing permissions. +class ScheduleTimeCorrectionScreen extends StatefulWidget { + const ScheduleTimeCorrectionScreen({ + super.key, + required this.scheduleId, + required this.workflow, + this.onSaved, + }); + final String scheduleId; + final ScheduleTimeCorrectionWorkflow workflow; + final VoidCallback? onSaved; + + @override + State createState() => + _ScheduleTimeCorrectionScreenState(); +} + +class _ScheduleTimeCorrectionScreenState + extends State { + ScheduleTimeCorrectionReview? _review; + DateTime? _civil; + String _zone = ''; + int? _offset; + List _choices = const []; + ScheduleSaveReceipt? _receipt; + ScheduleTimeCorrectionCommand? _command; + ScheduleTimeCorrectionCommand? _confirmedCommand; + String? _confirmedComparison; + bool _busy = false; + bool _confirming = false; + bool _needsReview = false; + bool _failed = false; + ScheduleSaveFailure? _failure; + List _conflicts = const []; + int _owner = 0; + int _selection = 0; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(ScheduleTimeCorrectionScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scheduleId != widget.scheduleId || + oldWidget.workflow != widget.workflow) { + _load(); + } + } + + bool _owns(int owner, [int? selection]) => + mounted && + owner == _owner && + (selection == null || selection == _selection); + + Future _load() async { + final owner = ++_owner; + setState(() { + _busy = true; + _review = null; + _receipt = null; + _command = null; + _confirmedCommand = null; + _confirmedComparison = null; + _confirming = false; + _needsReview = false; + _failed = false; + _failure = null; + _conflicts = const []; + }); + try { + final review = await widget.workflow.repository.review(widget.scheduleId); + if (!_owns(owner)) return; + setState(() { + _review = review; + _civil = CivilDateTime.fromFields( + review.snapshot.schedule.scheduleTime, + ).toUtcCarrier(); + _zone = review.snapshot.schedule.timeZoneId; + _offset = review.snapshot.schedule.occurrenceOffsetSeconds; + _resolve(); + _busy = false; + }); + } catch (_) { + if (!_owns(owner)) return; + setState(() { + _busy = false; + _failed = true; + }); + } + } + + void _resolve() { + _selection++; + _command = null; + _confirmedCommand = null; + _confirmedComparison = null; + _choices = _civil != null && TimeZoneRules.contains(_zone) + ? CivilTimeResolver.resolve(_civil!, _zone) + : const []; + if (!_choices.any((value) => value.offsetSeconds == _offset)) { + _offset = _choices.length == 1 ? _choices.single.offsetSeconds : null; + } + _failure = null; + _conflicts = const []; + _failed = false; + } + + Future _pickCivil(bool dateOnly) async { + final owner = _owner, selection = _selection; + final result = await showCivilDateTimePicker( + context: context, + initialCivil: _civil!, + dateOnly: dateOnly, + title: recurrenceText( + context, + dateOnly ? '날짜' : '시간', + dateOnly ? 'Date' : 'Time', + ), + ); + if (result == null || !_owns(owner, selection)) return; + setState(() { + _civil = result; + _offset = null; + _resolve(); + }); + } + + Future _pickZone() async { + final owner = _owner, selection = _selection; + final result = await showScheduleTimeZonePicker( + context: context, + currentZone: _zone, + civil: _civil, + offsetSeconds: _offset, + isCurrent: () => _owns(owner, selection), + ); + if (result == null || !_owns(owner, selection)) return; + setState(() { + _zone = result; + _offset = null; + _resolve(); + }); + } + + DateTime? get _selectedInstant { + for (final value in _choices) { + if (value.offsetSeconds == _offset) return value.instantUtc; + } + return null; + } + + DateTime? get _oldInstant { + final original = _review?.snapshot.schedule; + if (original?.occurrenceOffsetSeconds == null) return null; + try { + return CivilDateTime.fromFields( + original!.scheduleTime, + ).atOffset(original.occurrenceOffsetSeconds!); + } on FormatException { + return null; + } + } + + Future _confirm() async { + if (_busy || _confirming || _needsReview || _receipt != null) return; + final owner = _owner, selection = _selection; + final instant = _selectedInstant; + if (_review == null || instant == null || _review!.blockedBy != null) { + return; + } + var command = _command ??= ScheduleTimeCorrectionCommand( + review: _review!, + civil: CivilDateTime.fromFields(_civil!), + timeZoneId: _zone, + offsetSeconds: _offset!, + mutationId: const Uuid().v7(), + ); + var comparison = _confirmedComparison ?? _comparison(); + setState(() => _confirming = true); + try { + // An accepted command may already have committed when its response was + // lost. Replay its exact receipt identity before attempting a new review. + if (!identical(command, _confirmedCommand)) { + final proof = await widget.workflow.repository.reviewChoice(command); + if (!mounted || !_owns(owner, selection)) return; + if (proof.conflicts.isNotEmpty) { + setState(() { + _conflicts = proof.conflicts; + _confirming = false; + }); + return; + } + final acknowledged = proof.possibleOverlaps.map((e) => e.id).toSet(); + if (!const SetEquality().equals( + command.acknowledgedUncertainIds, + acknowledged, + )) { + command = ScheduleTimeCorrectionCommand( + review: command.review, + civil: command.civil, + timeZoneId: command.timeZoneId, + offsetSeconds: command.offsetSeconds, + mutationId: command.mutationId, + acknowledgedUncertainIds: acknowledged, + ); + _command = command; + } + if (proof.possibleOverlaps.isNotEmpty) { + comparison += + '\n\n${recurrenceText(context, '다음 일정은 정확한 충돌 판정이 불가능해요. 해당 일정의 새 시작·알림은 보류됩니다. 각 일정을 나중에 확인할 때 다시 충돌을 검사해요.', 'Exact overlap is uncertain for these appointments. Their new starts and alarms remain on hold. Their individual time reviews will check overlaps again.')}'; + comparison += + '\n${proof.possibleOverlaps.map(_possibleImpact).join('\n\n')}'; + } + } + } on ScheduleSaveRejected catch (error) { + if (!_owns(owner, selection)) return; + setState(() { + _confirming = false; + _failure = error.failure; + _needsReview = error.failure != ScheduleSaveFailure.invalid; + }); + return; + } catch (_) { + if (!_owns(owner, selection)) return; + setState(() { + _confirming = false; + _failed = true; + }); + return; + } + if (!mounted) return; + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + recurrenceText( + context, + '변경할 시간을 확인하세요', + 'Confirm the corrected time', + ), + ), + content: SingleChildScrollView(child: Text(comparison)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(recurrenceText(context, '취소', 'Cancel')), + ), + FilledButton( + key: const Key('confirm-time-correction'), + onPressed: () => Navigator.pop(context, true), + child: Text(recurrenceText(context, '확인 후 저장', 'Confirm and save')), + ), + ], + ), + ); + if (!_owns(owner, selection)) return; + setState(() => _confirming = false); + if (accepted != true) return; + _confirmedCommand = command; + _confirmedComparison = comparison; + setState(() { + _busy = true; + _failed = false; + _failure = null; + }); + try { + final receipt = await widget.workflow.confirm(command); + if (!_owns(owner, selection)) return; + _saved(receipt); + } on ScheduleTimeCorrectionConflict catch (error) { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _conflicts = error.proof.conflicts; + }); + } on ScheduleSaveRejected catch (error) { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _failure = error.failure; + _needsReview = error.failure != ScheduleSaveFailure.invalid; + }); + } catch (_) { + if (!_owns(owner, selection)) return; + setState(() { + _busy = false; + _failed = true; + }); + } + } + + void _saved(ScheduleSaveReceipt receipt) { + setState(() { + _busy = false; + _receipt = receipt; + }); + if (!receipt.deliveryPending) { + if (widget.onSaved != null) { + widget.onSaved!(); + } else { + Navigator.of(context).pop(true); + } + } + } + + Future _retryDelivery() async { + final owner = _owner; + setState(() => _busy = true); + final receipt = await widget.workflow.retryDelivery(_receipt!); + if (_owns(owner)) _saved(receipt); + } + + String _possibleImpact(TimeCorrectionPossibleOverlap value) { + final status = ScheduleTimeResolutionStatus.values.firstWhereOrNull( + (status) => status.name == value.reason, + ); + return [ + value.name, + if (value.originalCivil != null) + '${_format(value.originalCivil!)} · ${value.timeZoneId ?? ''}', + if (value.rule != null) + '${recurrenceLabel(context, value.rule!)} · ${recurrenceEndLabel(context, value.rule!)}', + if (status != null) _reason(status), + ].join('\n'); + } + + String _format(DateTime value) => DateFormat.yMd( + Localizations.localeOf(context).toString(), + ).add_Hms().format(value); + + String _comparison() { + final original = _review!.snapshot.schedule; + final instant = _selectedInstant; + final previous = _oldInstant; + return [ + '${recurrenceText(context, '저장된 시간', 'Saved time')}: ${_format(original.scheduleTime)} · ${original.timeZoneId}', + if (previous != null) + '${recurrenceText(context, '기존 순간', 'Previous instant')}: ${previous.toIso8601String()}', + '${recurrenceText(context, '선택한 시간', 'Selected time')}: ${_format(_civil!)} · $_zone', + if (instant != null) ...[ + '${recurrenceText(context, '새 순간', 'New instant')}: ${instant.toIso8601String()}', + '${recurrenceText(context, '기기 시각', 'Device time')}: ${_format(instant.toLocal())} ${instant.toLocal().timeZoneName}', + if (previous != null) + '${recurrenceText(context, '시간 차이(초)', 'Difference (seconds)')}: ${instant.difference(previous).inMicroseconds / Duration.microsecondsPerSecond}', + ], + ].join('\n\n'); + } + + String _reason(ScheduleTimeResolutionStatus status) => switch (status) { + ScheduleTimeResolutionStatus.unknownZone => recurrenceText( + context, + '저장된 시간대를 찾을 수 없어요. 시간대를 직접 선택해주세요.', + 'The saved time zone is unavailable. Choose a time zone.', + ), + ScheduleTimeResolutionStatus.nonexistent => recurrenceText( + context, + '시간대 전환으로 존재하지 않는 시각이에요.', + 'This wall time does not exist because of a time zone transition.', + ), + ScheduleTimeResolutionStatus.ambiguous => recurrenceText( + context, + '두 번 발생하는 시각이에요. 원하는 순간을 선택해주세요.', + 'This time occurs twice. Select the intended occurrence.', + ), + ScheduleTimeResolutionStatus.changed => recurrenceText( + context, + '시간대 규칙이 달라졌어요. 변경할 순간을 확인해주세요.', + 'Time zone rules changed. Review the intended instant.', + ), + ScheduleTimeResolutionStatus.historicalUncertain => recurrenceText( + context, + '과거의 정확한 순간을 확인할 수 없어요. 저장된 기록은 유지됩니다.', + 'The historical instant is uncertain. The saved record is preserved.', + ), + _ => recurrenceText( + context, + '저장된 시간 정보를 확인하세요.', + 'Review the saved time information.', + ), + }; + + @override + Widget build(BuildContext context) { + final review = _review; + final blocked = + _busy || + _confirming || + _needsReview || + review?.blockedBy != null || + _receipt != null; + final gap = + _civil != null && _choices.isEmpty && TimeZoneRules.contains(_zone) + ? CivilTimeResolver.nextValidCivilTime(_civil!, _zone) + : null; + return SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Text( + recurrenceText(context, '일정 시간 확인', 'Review schedule time'), + style: Theme.of(context).textTheme.headlineSmall, + ), + if (_busy) const LinearProgressIndicator(), + if (review != null) ...[ + const SizedBox(height: 16), + Text(review.snapshot.schedule.scheduleName), + Text(_reason(review.resolution.status)), + if (review.snapshot.schedule.isRecurring) + Text( + recurrenceText( + context, + '이번 회차의 시간만 변경하며 원래 반복 날짜와 준비 기록을 유지합니다.', + 'Only this occurrence changes; its original recurrence date and preparation records are preserved.', + ), + ), + if (review.blockedBy != null) + Text( + recurrenceText( + context, + '진행 중이거나 지난 기록은 이 화면에서 변경할 수 없어요.', + 'Running or historical records cannot be changed here.', + ), + ), + const SizedBox(height: 16), + Text(_comparison(), key: const Key('time-correction-comparison')), + ListTile( + key: const Key('correction-date'), + title: Text(recurrenceText(context, '날짜', 'Date')), + subtitle: Text(_format(_civil!)), + onTap: blocked ? null : () => _pickCivil(true), + ), + ListTile( + key: const Key('correction-time'), + title: Text(recurrenceText(context, '시간', 'Time')), + subtitle: Text(_format(_civil!)), + onTap: blocked ? null : () => _pickCivil(false), + ), + ListTile( + key: const Key('correction-zone'), + title: Text(_zone), + subtitle: Text( + recurrenceText(context, '시간대를 직접 선택', 'Choose time zone'), + ), + onTap: blocked ? null : _pickZone, + ), + if (gap != null) + TextButton( + key: const Key('correction-gap-proposal'), + onPressed: blocked + ? null + : () => setState(() { + _civil = gap; + _offset = null; + _resolve(); + }), + child: Text( + '${recurrenceText(context, '다음 유효 시각 선택', 'Select next valid time')}: ${_format(gap)}', + ), + ), + for (final choice in _choices) + ListTile( + key: ValueKey('correction-offset-${choice.offsetSeconds}'), + leading: Icon( + _offset == choice.offsetSeconds + ? Icons.radio_button_checked + : Icons.radio_button_off, + ), + title: Text( + CivilTimeResolver.formatUtcOffset(choice.offsetSeconds), + ), + subtitle: Text(choice.instantUtc.toIso8601String()), + selected: choice.offsetSeconds == _offset, + onTap: blocked + ? null + : () => setState(() { + _offset = choice.offsetSeconds; + _selection++; + _command = null; + _confirmedCommand = null; + _confirmedComparison = null; + _conflicts = const []; + _failure = null; + }), + ), + if (_receipt == null) + FilledButton( + key: const Key('review-time-correction'), + onPressed: blocked || _selectedInstant == null + ? null + : _confirm, + child: Text( + recurrenceText(context, '변경 내용 확인', 'Review changes'), + ), + ), + ], + if (_conflicts.isNotEmpty) + Text( + '${recurrenceText(context, '다른 약속의 준비 시간과 겹쳐요. 시간을 다시 선택해주세요.', 'This overlaps another appointment or its preparation. Choose another time.')}\n${_conflicts.map((e) => e.other.schedule.scheduleName).toSet().join(', ')}', + key: const Key('time-correction-overlap'), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (_failure != null || _failed) ...[ + Text( + _failure == ScheduleSaveFailure.invalid + ? recurrenceText( + context, + '새 준비 시작과 약속 시각이 모두 현재보다 나중이어야 해요. 시각을 다시 선택해주세요.', + 'Both the new preparation start and appointment must be in the future. Choose another time.', + ) + : recurrenceText( + context, + '일정이나 시간 기준이 바뀌었거나 작업을 마칠 수 없어요. 최신 기록을 다시 확인해주세요.', + 'The schedule or time authority changed, or the operation could not finish. Review the latest record.', + ), + ), + TextButton( + onPressed: _busy ? null : _load, + child: Text( + recurrenceText(context, '최신 기록 다시 확인', 'Review latest record'), + ), + ), + ], + if (_receipt?.deliveryPending ?? false) ...[ + Text( + recurrenceText( + context, + '시간은 저장되었지만 알림 동기화가 아직 끝나지 않았어요.', + 'The time was saved. Notification synchronization is still pending.', + ), + ), + TextButton( + onPressed: _busy ? null : _retryDelivery, + child: Text( + recurrenceText( + context, + '알림 동기화 다시 시도', + 'Retry notification synchronization', + ), + ), + ), + ], + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: Text(recurrenceText(context, '닫기', 'Close')), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart b/lib/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart new file mode 100644 index 00000000..76266cb4 --- /dev/null +++ b/lib/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_time_save_review.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; + +Future showScheduleTimeSaveReview({ + required BuildContext context, + required ScheduleTimeSaveReview review, +}) => showDialog( + context: context, + builder: (context) { + final l = AppLocalizations.of(context)!; + final original = review.original; + return AlertDialog( + scrollable: true, + title: Text(l.zonedTimeReviewTitle), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.zonedTimeReviewDescription), + if (original != null) ...[ + const SizedBox(height: 16), + Text( + l.zonedTimeBefore, + style: Theme.of(context).textTheme.titleSmall, + ), + ScheduleZonedTime( + civil: CivilDateTime.fromFields(original.scheduleTime), + timeZoneId: original.timeZoneId, + resolution: ScheduleTimeResolver.resolve( + original, + nowUtc: review.reviewedAtUtc, + ), + showInstant: true, + ), + ], + const SizedBox(height: 16), + Text(l.zonedTimeAfter, style: Theme.of(context).textTheme.titleSmall), + ScheduleZonedTime( + civil: CivilDateTime.fromFields(review.proposed.scheduleTime), + timeZoneId: review.proposed.timeZoneId, + resolution: review.resolution, + showInstant: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.cancel), + ), + FilledButton( + key: const ValueKey('schedule-time-review-confirm'), + onPressed: () => Navigator.pop(context, true), + child: Text(l.zonedTimeConfirmSave), + ), + ], + ); + }, +); diff --git a/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart b/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart index b9f931b9..aaa74000 100644 --- a/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart +++ b/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart @@ -59,70 +59,85 @@ class _BottomNavigationBarScaffoldState extends State { body: widget.child, bottomNavigationBar: SafeArea( top: false, - child: SizedBox( - height: 56, - child: Row( - children: [ - Expanded( - child: InkWell( - onTap: () => onDestinationSelected(0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.home_outlined, size: 26), - Text( - AppLocalizations.of(context)!.home, - style: const TextStyle(fontSize: 11), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: InkWell( + onTap: () => onDestinationSelected(0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.home_outlined, size: 26), + Text( + AppLocalizations.of(context)!.home, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 11), + ), + ], ), - ], + ), ), ), - ), - Expanded( - child: InkWell( - onTap: () => showModalBottomSheet( - context: context, - isDismissible: false, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => const ScheduleCreateScreen(), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_circle, - color: colorScheme.primary, - size: 30, + Expanded( + child: InkWell( + onTap: () => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const ScheduleCreateScreen(), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle, + color: colorScheme.primary, + size: 30, + ), + Text( + AppLocalizations.of(context)!.addAppointment, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + ), + ], ), - Text( - AppLocalizations.of(context)!.addAppointment, - style: const TextStyle(fontSize: 10), - ), - ], + ), ), ), - ), - Expanded( - child: InkWell( - onTap: () => onDestinationSelected(1), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.person_outline, - color: colorScheme.primary, - size: 26, - ), - Text( - AppLocalizations.of(context)!.myPage, - style: const TextStyle(fontSize: 11), + Expanded( + child: InkWell( + onTap: () => onDestinationSelected(1), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_outline, + color: colorScheme.primary, + size: 26, + ), + Text( + AppLocalizations.of(context)!.myPage, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 11), + ), + ], ), - ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/presentation/shared/components/civil_date_time_picker.dart b/lib/presentation/shared/components/civil_date_time_picker.dart new file mode 100644 index 00000000..ae26e50d --- /dev/null +++ b/lib/presentation/shared/components/civil_date_time_picker.dart @@ -0,0 +1,277 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; + +/// Edits schedule wall fields without the device's DST normalization. +Future showCivilDateTimePicker({ + required BuildContext context, + required DateTime initialCivil, + required bool dateOnly, + required String title, +}) { + final carrier = CivilDateTime.fromFields(initialCivil).toUtcCarrier(); + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _CivilPickerSheet( + initialCivil: carrier, + dateOnly: dateOnly, + title: title, + ), + ); +} + +class _CivilPickerSheet extends StatefulWidget { + const _CivilPickerSheet({ + required this.initialCivil, + required this.dateOnly, + required this.title, + }); + + final DateTime initialCivil; + final bool dateOnly; + final String title; + + @override + State<_CivilPickerSheet> createState() => _CivilPickerSheetState(); +} + +class _CivilPickerSheetState extends State<_CivilPickerSheet> { + late int _year, _month, _day, _hour, _minute; + late final FixedExtentScrollController _years, + _months, + _days, + _hours, + _minutes; + + @override + void initState() { + super.initState(); + final initial = widget.initialCivil; + _year = initial.year; + _month = initial.month; + _day = initial.day; + _hour = initial.hour; + _minute = initial.minute; + _years = FixedExtentScrollController(initialItem: _year - 1); + _months = FixedExtentScrollController(initialItem: _month - 1); + _days = FixedExtentScrollController(initialItem: _day - 1); + _hours = FixedExtentScrollController(initialItem: _hour); + _minutes = FixedExtentScrollController(initialItem: _minute); + } + + @override + void dispose() { + for (final controller in [_years, _months, _days, _hours, _minutes]) { + controller.dispose(); + } + super.dispose(); + } + + int get _daysInMonth => DateTime.utc(_year, _month + 1, 0).day; + + DateTime get _selection => DateTime.utc( + _year, + _month, + _day, + _hour, + _minute, + widget.initialCivil.second, + widget.initialCivil.millisecond, + widget.initialCivil.microsecond, + ); + + void _changeCalendar({int? year, int? month}) { + final previousDay = _day; + setState(() { + _year = year ?? _year; + _month = month ?? _month; + if (_day > _daysInMonth) { + _day = _daysInMonth; + } + }); + // Match the visible wheel and preview before the user can confirm a shorter + // month. This edits only this dialog's draft, never a saved schedule. + if (_day != previousDay && _days.hasClients) { + _days.jumpToItem(_day - 1); + } + } + + Widget _wheel({ + required String name, + required String label, + required FixedExtentScrollController controller, + required int count, + required int firstValue, + required ValueChanged onChanged, + }) { + final extent = (MediaQuery.textScalerOf(context).scale(20) + 12) + .clamp(44.0, 96.0) + .toDouble(); + return Expanded( + child: Column( + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + Expanded( + child: Semantics( + label: label, + child: CupertinoPicker.builder( + key: ValueKey('civil-picker-$name'), + scrollController: controller, + itemExtent: extent, + childCount: count, + onSelectedItemChanged: (index) => onChanged(index + firstValue), + itemBuilder: (context, index) => Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + (index + firstValue).toString().padLeft( + name == 'year' ? 1 : 2, + '0', + ), + maxLines: 1, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context); + final material = MaterialLocalizations.of(context); + final locale = Localizations.localeOf(context); + final korean = locale.languageCode == 'ko'; + final wheelHeight = + (MediaQuery.textScalerOf(context).scale(20) + 12) + .clamp(44.0, 96.0) + .toDouble() * + 3 + + 56; + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * .85, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + DateFormat.yMd( + locale.toString(), + ).add_Hms().format(_selection), + key: const ValueKey('civil-picker-preview'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: wheelHeight, + child: Row( + children: widget.dateOnly + ? [ + _wheel( + name: 'year', + label: korean ? '년' : 'Year', + controller: _years, + count: 9999, + firstValue: 1, + onChanged: (value) => _changeCalendar(year: value), + ), + _wheel( + name: 'month', + label: korean ? '월' : 'Month', + controller: _months, + count: 12, + firstValue: 1, + onChanged: (value) => _changeCalendar(month: value), + ), + _wheel( + name: 'day', + label: korean ? '일' : 'Day', + controller: _days, + count: _daysInMonth, + firstValue: 1, + onChanged: (value) { + if (value >= 1 && + value <= _daysInMonth && + value != _day) { + setState(() => _day = value); + } + }, + ), + ] + : [ + _wheel( + name: 'hour', + label: korean ? '시' : 'Hour', + controller: _hours, + count: 24, + firstValue: 0, + onChanged: (value) => setState(() => _hour = value), + ), + _wheel( + name: 'minute', + label: korean ? '분' : 'Minute', + controller: _minutes, + count: 60, + firstValue: 0, + onChanged: (value) => + setState(() => _minute = value), + ), + ], + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + key: const ValueKey('civil-picker-cancel'), + onPressed: () => Navigator.pop(context), + child: Text( + strings?.cancel ?? material.cancelButtonLabel, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FilledButton( + key: const ValueKey('civil-picker-confirm'), + onPressed: () => Navigator.pop(context, _selection), + child: Text(strings?.ok ?? material.okButtonLabel), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/shared/time/date_basis_info.dart b/lib/presentation/shared/time/date_basis_info.dart new file mode 100644 index 00000000..b600cd28 --- /dev/null +++ b/lib/presentation/shared/time/date_basis_info.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; + +/// Explains existing date grouping without changing any schedule or query. +class DateBasisInfo extends StatelessWidget { + const DateBasisInfo({super.key, required this.home}); + final bool home; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final caption = home + ? l.zonedTimeHomeDateBasis + : l.zonedTimeCalendarDateBasis; + return TextButton.icon( + key: ValueKey(home ? 'home-date-basis' : 'calendar-date-basis'), + style: TextButton.styleFrom(alignment: Alignment.centerLeft), + icon: const Icon(Icons.info_outline, size: 18), + label: Text(caption, style: Theme.of(context).textTheme.bodySmall), + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text(l.zonedTimeDateBasisTitle), + content: Text(l.zonedTimeDateBasisDescription), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l.ok), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/shared/time/device_time_zone_scope.dart b/lib/presentation/shared/time/device_time_zone_scope.dart new file mode 100644 index 00000000..58946fac --- /dev/null +++ b/lib/presentation/shared/time/device_time_zone_scope.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:on_time_front/core/services/local_time_zone_service.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'device_time_zone_state.dart'; + +/// Read-only setting observation. It never updates a schedule or its draft. +final class DeviceTimeZoneController + extends ValueNotifier { + DeviceTimeZoneController({Future Function()? readZone}) + : _readZone = readZone ?? LocalTimeZoneService.current, + super(const DeviceTimeZoneState.loading()); + + final Future Function() _readZone; + int _revision = 0; + bool _disposed = false; + bool _refreshing = false; + bool get isRefreshing => _refreshing; + + Future refresh() async { + if (_disposed) return; + final revision = ++_revision; + _refreshing = true; + try { + final identifier = await _readZone(); + if (_disposed || revision != _revision) return; + value = TimeZoneRules.contains(identifier) + ? DeviceTimeZoneState.known(identifier) + : const DeviceTimeZoneState.unavailable(); + } catch (_) { + if (!_disposed && revision == _revision) { + value = const DeviceTimeZoneState.unavailable(); + } + } finally { + if (!_disposed && revision == _revision) _refreshing = false; + } + } + + void invalidatePendingRead() { + _revision++; + _refreshing = false; + } + + @override + void dispose() { + _disposed = true; + invalidatePendingRead(); + super.dispose(); + } +} + +/// One observation owner above the router, shared by pages and modal routes. +class DeviceTimeZoneScope extends StatefulWidget { + const DeviceTimeZoneScope({super.key, required this.child, this.controller}); + final Widget child; + final DeviceTimeZoneController? controller; + + static DeviceTimeZoneState stateOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType<_DeviceTimeZoneInherited>() + ?.notifier + ?.value ?? + const DeviceTimeZoneState.unavailable(); + + @override + State createState() => _DeviceTimeZoneScopeState(); +} + +class _DeviceTimeZoneScopeState extends State + with WidgetsBindingObserver { + late DeviceTimeZoneController _controller; + Timer? _timer; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? DeviceTimeZoneController(); + WidgetsBinding.instance.addObserver(this); + _startIfActive(); + } + + @override + void didUpdateWidget(DeviceTimeZoneScope oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(oldWidget.controller, widget.controller)) return; + _timer?.cancel(); + if (oldWidget.controller == null) { + _controller.dispose(); + } else { + _controller.invalidatePendingRead(); + } + _controller = widget.controller ?? DeviceTimeZoneController(); + _startIfActive(); + } + + void _startIfActive() { + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle == null || lifecycle == AppLifecycleState.resumed) _resume(); + } + + void _resume() { + unawaited(_controller.refresh()); + _timer?.cancel(); + // A named zone can change without a different offset/abbreviation. Observe + // the platform setting itself, only while this app's UI is active. + _timer = Timer.periodic(const Duration(minutes: 1), (_) { + if (!_controller.isRefreshing) unawaited(_controller.refresh()); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _resume(); + } else { + _timer?.cancel(); + _controller.invalidatePendingRead(); + } + } + + @override + Widget build(BuildContext context) => + _DeviceTimeZoneInherited(notifier: _controller, child: widget.child); + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _timer?.cancel(); + if (widget.controller == null) { + _controller.dispose(); + } else { + _controller.invalidatePendingRead(); + } + super.dispose(); + } +} + +class _DeviceTimeZoneInherited + extends InheritedNotifier { + const _DeviceTimeZoneInherited({ + required super.notifier, + required super.child, + }); +} diff --git a/lib/presentation/shared/time/device_time_zone_state.dart b/lib/presentation/shared/time/device_time_zone_state.dart new file mode 100644 index 00000000..aa9e17af --- /dev/null +++ b/lib/presentation/shared/time/device_time_zone_state.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +enum DeviceTimeZoneStatus { loading, known, unavailable } + +/// An observed device setting, never the authority for a saved commitment. +final class DeviceTimeZoneState extends Equatable { + const DeviceTimeZoneState.loading() + : status = DeviceTimeZoneStatus.loading, + identifier = null; + const DeviceTimeZoneState.known(String zone) + : status = DeviceTimeZoneStatus.known, + identifier = zone; + const DeviceTimeZoneState.unavailable() + : status = DeviceTimeZoneStatus.unavailable, + identifier = null; + + final DeviceTimeZoneStatus status; + final String? identifier; + + @override + List get props => [status, identifier]; +} diff --git a/lib/presentation/shared/time/schedule_zoned_time.dart b/lib/presentation/shared/time/schedule_zoned_time.dart new file mode 100644 index 00000000..ed862653 --- /dev/null +++ b/lib/presentation/shared/time/schedule_zoned_time.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'device_time_zone_scope.dart'; +import 'device_time_zone_state.dart'; +import 'schedule_zoned_time_text.dart'; +import 'zoned_schedule_presentation.dart'; + +class ScheduleZonedTime extends StatelessWidget { + const ScheduleZonedTime({ + super.key, + required this.civil, + required this.timeZoneId, + required this.resolution, + this.deviceTimeZone, + this.showInstant = false, + this.showResolutionStatus = true, + this.style, + }); + + final CivilDateTime civil; + final String timeZoneId; + final ScheduleTimeResolution resolution; + final DeviceTimeZoneState? deviceTimeZone; + final bool showInstant; + + /// False only when the containing form already provides occurrence guidance. + /// Device observation and conversion messages remain visible. + final bool showResolutionStatus; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final text = ScheduleZonedTimeText.format( + presentation: ZonedSchedulePresentation.from( + civil: civil, + scheduleTimeZoneId: timeZoneId, + resolution: resolution, + deviceTimeZone: deviceTimeZone ?? DeviceTimeZoneScope.stateOf(context), + ), + l10n: l10n, + use24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + showResolutionStatus: showResolutionStatus, + ); + final instant = showInstant ? text.instant : null; + return Semantics( + label: [ + text.semanticsLabel, + if (instant != null) '${l10n.zonedTimeInstant}: $instant', + ].join('. '), + child: ExcludeSemantics( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${l10n.zonedTimeOriginal}: ${text.original}', style: style), + if (text.device != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${l10n.zonedTimeDevice}: ${text.device}', + style: style, + ), + ), + if (text.statusMessage != null) + Text(text.statusMessage!, style: style), + if (instant != null) + Text('${l10n.zonedTimeInstant}: $instant', style: style), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/shared/time/schedule_zoned_time_text.dart b/lib/presentation/shared/time/schedule_zoned_time_text.dart new file mode 100644 index 00000000..7483d5e5 --- /dev/null +++ b/lib/presentation/shared/time/schedule_zoned_time_text.dart @@ -0,0 +1,107 @@ +import 'package:intl/intl.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'device_time_zone_state.dart'; +import 'zoned_schedule_presentation.dart'; + +final class ScheduleZonedTimeText { + const ScheduleZonedTimeText._({ + required this.original, + required this.device, + required this.statusMessage, + required this.semanticsLabel, + required this.instant, + }); + + factory ScheduleZonedTimeText.format({ + required ZonedSchedulePresentation presentation, + required AppLocalizations l10n, + required bool use24HourFormat, + bool showResolutionStatus = true, + }) { + String line(ZonedCivilTime value) => [ + formatCivil( + value.civil, + locale: l10n.localeName, + use24HourFormat: use24HourFormat, + ), + value.timeZoneId.isEmpty ? l10n.zonedTimeUnknownZone : value.timeZoneId, + if (value.offsetSeconds != null) + CivilTimeResolver.formatUtcOffset(value.offsetSeconds!), + ].join(' · '); + final original = line(presentation.original); + final device = presentation.showDeviceEquivalent + ? line(presentation.device!) + : null; + final resolution = presentation.resolution; + final unresolved = switch (resolution.status) { + ScheduleTimeResolutionStatus.resolved => null, + ScheduleTimeResolutionStatus.historicalUncertain => + l10n.zonedTimeHistoricalUncertain, + ScheduleTimeResolutionStatus.unknownZone => + resolution.isHistorical + ? l10n.zonedTimeHistoricalUnknownZone + : l10n.zonedTimeUnknownZone, + ScheduleTimeResolutionStatus.nonexistent => l10n.zonedTimeNonexistent, + ScheduleTimeResolutionStatus.ambiguous => l10n.zonedTimeAmbiguous, + ScheduleTimeResolutionStatus.changed => l10n.zonedTimeRulesChanged, + ScheduleTimeResolutionStatus.invalid => l10n.zonedTimeInvalid, + }; + final deviceStatus = switch (presentation.deviceTimeZone.status) { + DeviceTimeZoneStatus.loading => l10n.zonedTimeDeviceLoading, + DeviceTimeZoneStatus.unavailable => l10n.zonedTimeDeviceUnavailable, + DeviceTimeZoneStatus.known => null, + }; + final messages = [ + if (showResolutionStatus) ?unresolved, + ?deviceStatus, + if (presentation.deviceConversionStatus == + DeviceTimeConversionStatus.outOfRange) + l10n.zonedTimeDeviceOutOfRange, + ]; + final status = messages.isEmpty ? null : messages.join('\n'); + return ScheduleZonedTimeText._( + original: original, + device: device, + statusMessage: status, + instant: presentation.instantUtc?.toIso8601String(), + semanticsLabel: [ + '${l10n.zonedTimeOriginal}: $original', + if (device != null) '${l10n.zonedTimeDevice}: $device', + ...messages, + ].join('. '), + ); + } + + final String original; + final String? device; + final String? statusMessage; + final String semanticsLabel; + final String? instant; + + /// Format wall fields without consulting the device zone. Retain any stored + /// seconds and fractional seconds, including microseconds beyond intl's SSS. + static String formatCivil( + CivilDateTime civil, { + required String locale, + required bool use24HourFormat, + }) { + final value = civil.toUtcCarrier(); + final micros = value.millisecond * 1000 + value.microsecond; + final precise = value.second != 0 || micros != 0; + final time = use24HourFormat + ? (precise ? DateFormat.Hms(locale) : DateFormat.Hm(locale)) + : (precise ? DateFormat.jms(locale) : DateFormat.jm(locale)); + var pattern = time.pattern!; + if (micros != 0) { + final fraction = micros.toString().padLeft(6, '0'); + pattern = pattern.replaceFirstMapped( + RegExp(r's+'), + (match) => "${match.group(0)}'.$fraction'", + ); + } + return '${DateFormat.yMd(locale).format(value)} ${DateFormat(pattern, locale).format(value)}'; + } +} diff --git a/lib/presentation/shared/time/time_zone_catalog.dart b/lib/presentation/shared/time/time_zone_catalog.dart new file mode 100644 index 00000000..454121b3 --- /dev/null +++ b/lib/presentation/shared/time/time_zone_catalog.dart @@ -0,0 +1,70 @@ +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/l10n/app_localizations_en.dart'; +import 'package:on_time_front/l10n/app_localizations_ko.dart'; + +/// Bundled translated search aliases supplement every supported IANA identifier. +/// Displaying a city never rewrites a saved zone. +abstract final class TimeZoneCatalog { + static Map _cities(AppLocalizations l) => { + 'Asia/Seoul': l.zonedCityAsiaSeoul, + 'Asia/Tokyo': l.zonedCityAsiaTokyo, + 'Asia/Shanghai': l.zonedCityAsiaShanghai, + 'Asia/Hong_Kong': l.zonedCityAsiaHongKong, + 'Asia/Taipei': l.zonedCityAsiaTaipei, + 'Asia/Singapore': l.zonedCityAsiaSingapore, + 'Asia/Bangkok': l.zonedCityAsiaBangkok, + 'Asia/Dubai': l.zonedCityAsiaDubai, + 'Asia/Kolkata': l.zonedCityAsiaKolkata, + 'Asia/Kathmandu': l.zonedCityAsiaKathmandu, + 'Europe/London': l.zonedCityEuropeLondon, + 'Europe/Paris': l.zonedCityEuropeParis, + 'Europe/Berlin': l.zonedCityEuropeBerlin, + 'Europe/Rome': l.zonedCityEuropeRome, + 'Europe/Madrid': l.zonedCityEuropeMadrid, + 'Europe/Moscow': l.zonedCityEuropeMoscow, + 'America/New_York': l.zonedCityAmericaNewYork, + 'America/Los_Angeles': l.zonedCityAmericaLosAngeles, + 'America/Chicago': l.zonedCityAmericaChicago, + 'America/Denver': l.zonedCityAmericaDenver, + 'America/Toronto': l.zonedCityAmericaToronto, + 'America/Vancouver': l.zonedCityAmericaVancouver, + 'America/Sao_Paulo': l.zonedCityAmericaSaoPaulo, + 'Australia/Sydney': l.zonedCityAustraliaSydney, + 'Australia/Melbourne': l.zonedCityAustraliaMelbourne, + 'Australia/Perth': l.zonedCityAustraliaPerth, + 'Australia/Lord_Howe': l.zonedCityAustraliaLordHowe, + 'Pacific/Auckland': l.zonedCityPacificAuckland, + 'Pacific/Honolulu': l.zonedCityPacificHonolulu, + 'Pacific/Apia': l.zonedCityPacificApia, + 'Africa/Cairo': l.zonedCityAfricaCairo, + 'Africa/Johannesburg': l.zonedCityAfricaJohannesburg, + 'UTC': l.zonedCityUtc, + }; + static final _english = _cities(AppLocalizationsEn()); + static final _korean = _cities(AppLocalizationsKo()); + + static String _normalize(String text) => text + .toLowerCase() + .replaceAll('_', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + static String cityName(String identifier, String languageCode) => + (languageCode.split('_').first == 'ko' + ? _korean + : _english)[identifier] ?? + identifier.split('/').last.replaceAll('_', ' '); + + static List search(String query) { + final term = _normalize(query); + return TimeZoneRules.identifiers + .where((identifier) { + final searchable = _normalize( + '$identifier ${cityName(identifier, 'en')} ${_korean[identifier] ?? ''}', + ); + return searchable.contains(term); + }) + .toList(growable: false); + } +} diff --git a/lib/presentation/shared/time/zoned_schedule_presentation.dart b/lib/presentation/shared/time/zoned_schedule_presentation.dart new file mode 100644 index 00000000..b8f39e37 --- /dev/null +++ b/lib/presentation/shared/time/zoned_schedule_presentation.dart @@ -0,0 +1,119 @@ +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'device_time_zone_state.dart'; + +enum DeviceTimeConversionStatus { + available, + unresolvedSchedule, + loading, + unavailable, + outOfRange, +} + +/// Wall fields and the offset at this occurrence, not today's zone offset. +final class ZonedCivilTime { + const ZonedCivilTime({ + required this.civil, + required this.timeZoneId, + required this.offsetSeconds, + }); + + final CivilDateTime civil; + final String timeZoneId; + final int? offsetSeconds; +} + +/// Pure display projection of one already interpreted commitment. +/// +/// It does not resolve missing history, select an occurrence, read a clock or +/// write data. In particular, unknown zones do not gain a device equivalent. +final class ZonedSchedulePresentation { + ZonedSchedulePresentation._({ + required this.original, + required this.device, + required this.resolution, + required this.deviceTimeZone, + required this.instantUtc, + required this.deviceConversionStatus, + }); + + factory ZonedSchedulePresentation.from({ + required CivilDateTime civil, + required String scheduleTimeZoneId, + required ScheduleTimeResolution resolution, + required DeviceTimeZoneState deviceTimeZone, + }) { + final instant = resolution.status == ScheduleTimeResolutionStatus.resolved + ? resolution.instantUtc?.toUtc() + : null; + final offset = instant == null + ? null + : civil.toUtcCarrier().difference(instant).inSeconds; + ZonedCivilTime? device; + var observed = deviceTimeZone; + var conversion = switch (observed.status) { + DeviceTimeZoneStatus.loading => DeviceTimeConversionStatus.loading, + DeviceTimeZoneStatus.unavailable => + DeviceTimeConversionStatus.unavailable, + DeviceTimeZoneStatus.known => + DeviceTimeConversionStatus.unresolvedSchedule, + }; + final deviceId = observed.identifier; + if (observed.status == DeviceTimeZoneStatus.known) { + if (deviceId == null || !TimeZoneRules.contains(deviceId)) { + observed = const DeviceTimeZoneState.unavailable(); + conversion = DeviceTimeConversionStatus.unavailable; + } else if (instant != null) { + try { + final converted = CivilTimeResolver.civilTimeAt(instant, deviceId); + device = ZonedCivilTime( + civil: CivilDateTime.fromFields(converted), + timeZoneId: deviceId, + offsetSeconds: converted.timeZoneOffset.inSeconds, + ); + conversion = DeviceTimeConversionStatus.available; + } on FormatException { + conversion = DeviceTimeConversionStatus.outOfRange; + } on Exception { + observed = const DeviceTimeZoneState.unavailable(); + conversion = DeviceTimeConversionStatus.unavailable; + } + } + } + return ZonedSchedulePresentation._( + original: ZonedCivilTime( + civil: civil, + timeZoneId: scheduleTimeZoneId, + offsetSeconds: offset, + ), + device: device, + resolution: resolution, + deviceTimeZone: observed, + instantUtc: instant, + deviceConversionStatus: conversion, + ); + } + + final ZonedCivilTime original; + final ZonedCivilTime? device; + final ScheduleTimeResolution resolution; + final DeviceTimeZoneState deviceTimeZone; + final DateTime? instantUtc; + final DeviceTimeConversionStatus deviceConversionStatus; + + // Different offsets are neither necessary nor sufficient to identify zones. + // Preserve the supplied names; do not guess alias equivalence. + bool get showDeviceEquivalent => + device != null && device!.timeZoneId != original.timeZoneId; + + bool get hasDifferentDeviceDate { + final other = device?.civil.toUtcCarrier(); + if (other == null) return false; + final own = original.civil.toUtcCarrier(); + return own.year != other.year || + own.month != other.month || + own.day != other.day; + } +} diff --git a/lib/presentation/startup/screens/local_startup_gate.dart b/lib/presentation/startup/screens/local_startup_gate.dart index 0f92757a..848373c4 100644 --- a/lib/presentation/startup/screens/local_startup_gate.dart +++ b/lib/presentation/startup/screens/local_startup_gate.dart @@ -1,24 +1,42 @@ -import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/core/database/recovery/recovery_restore_service.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; +import 'package:on_time_front/domain/ports/recovery_restore_port.dart'; +import 'recovery_restore_screen.dart'; import 'dart:async'; +import 'package:on_time_front/core/database/bootstrap_privacy_boundary.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:on_time_front/core/database/local_data_lifecycle.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:on_time_front/core/database/local_reset_protocol.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; import 'local_reset_progress_screen.dart'; -/// Minimal pre-DI recovery surface. The broader D01 startup transaction remains -/// separate; a failure during DI construction requires a restart, not reuse. +/// Visible before DB/DI. A waiting notice never cancels the actual owner. class LocalStartupGate extends StatefulWidget { const LocalStartupGate({ super.key, required this.prepare, required this.ready, required this.retryReset, + this.beginReset, + this.recovery, + this.stage, + this.cleanupAttempt, + this.waitNoticeAfter = const Duration(seconds: 10), }); final Future Function() prepare; final Widget Function() ready; final Future Function() retryReset; + final Future Function()? beginReset; + final Future Function()? cleanupAttempt; + final Future Function()? recovery; + final Duration waitNoticeAfter; + final ValueListenable? stage; @override State createState() => _LocalStartupGateState(); } @@ -26,41 +44,133 @@ class LocalStartupGate extends StatefulWidget { class _LocalStartupGateState extends State { Widget? _ready; Object? _failure; - bool _busy = true; - bool _canRetry = true; + bool _busy = false; bool _waiting = false; + bool _needsCleanup = false; + bool _canRetry = true; + bool _confirming = false; + bool _resetStarted = false; + bool _restoreRequested = false; Timer? _timer; @override void initState() { super.initState(); + widget.stage?.addListener(_stageChanged); unawaited(_load()); } + void _stageChanged() { + if (mounted) setState(() {}); + } + + @override + void didUpdateWidget(covariant LocalStartupGate oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.stage != widget.stage) { + oldWidget.stage?.removeListener(_stageChanged); + widget.stage?.addListener(_stageChanged); + } + } + Future _load() async { + if (_busy || _resetStarted) return; setState(() { _busy = true; _failure = null; _waiting = false; }); - _timer = Timer(const Duration(seconds: 10), () { + _timer = Timer(widget.waitNoticeAfter, () { if (mounted && _busy) setState(() => _waiting = true); }); + var constructing = false; + var attemptingCleanup = false; try { + if (_needsCleanup) { + attemptingCleanup = true; + await widget.cleanupAttempt?.call(); + attemptingCleanup = false; + _needsCleanup = false; + } await widget.prepare(); - if (!mounted) return; - _canRetry = false; + if (!mounted) { + await widget.cleanupAttempt?.call(); + return; + } + constructing = true; final app = widget.ready(); - setState(() => _ready = app); - } catch (error) { - if (mounted) setState(() => _failure = error); + if (mounted) setState(() => _ready = app); + } catch (original) { + Object failure = original; + _needsCleanup = widget.cleanupAttempt != null; + if (_needsCleanup && !attemptingCleanup) { + try { + await widget.cleanupAttempt!(); + _needsCleanup = false; + } catch (_) { + failure = const StartupCleanupIncomplete(); + } + } + if (attemptingCleanup) failure = const StartupCleanupIncomplete(); + _canRetry = !constructing || widget.cleanupAttempt != null; + if (mounted) setState(() => _failure = failure); } finally { _timer?.cancel(); if (mounted) setState(() => _busy = false); } } + Iterable get _failureChain sync* { + Object? current = _failure; + final seen = Set.identity(); + while (current != null && seen.add(current)) { + yield current; + current = switch (current) { + StartupFailure failure => failure.cause, + BootstrapPrivacyCleanupFailure failure => failure.bootstrapError, + _ => null, + }; + } + } + + Object? get _cause => _failureChain.lastOrNull; + bool get _privacyCleanupPending => + _failureChain.any((value) => value is BootstrapPrivacyCleanupFailure); + + Future _confirmReset(BuildContext context, bool ko) async { + if (_busy || _confirming || _resetStarted) return; + setState(() => _confirming = true); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text(ko ? '복구하지 않고 삭제할까요?' : 'Delete without recovering?'), + content: Text( + ko + ? '이 설치의 모든 로컬 데이터와 암호화 키가 삭제됩니다. 되돌릴 수 없습니다. 외부 백업 파일은 삭제되지 않습니다.' + : 'All local data and encryption keys in this installation will be deleted. This cannot be undone. External backup files are not deleted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(ko ? '취소' : 'Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(ko ? '모두 삭제' : 'Delete all'), + ), + ], + ), + ); + if (!mounted) return; + setState(() { + _confirming = false; + _resetStarted = confirmed == true; + }); + } + @override void dispose() { + widget.stage?.removeListener(_stageChanged); _timer?.cancel(); super.dispose(); } @@ -74,47 +184,172 @@ class _LocalStartupGateState extends State { supportedLocales: AppLocalizations.supportedLocales, home: Builder( builder: (context) { - final failure = _failure; - if (failure is LocalResetRecoveryRequired) { + final cause = _cause; + if ((_restoreRequested || + (!_busy && cause is PairRecoveryRequired)) && + widget.recovery != null) { + return RecoveryRestoreScreen( + port: widget.recovery!, + initialReceipt: cause is PairRecoveryRequired + ? cause.receipt + : null, + onCancelled: () => setState(() { + _restoreRequested = false; + if (cause is PairRecoveryRequired) { + _failure = const RestoreStoreUnavailable( + PairAuthorityUnavailable(), + ); + } + }), + onCompleted: () { + setState(() => _restoreRequested = false); + unawaited(_load()); + }, + ); + } + if (_resetStarted) { + return LocalResetProgressScreen(operation: widget.beginReset!); + } + if (!_busy && cause is LocalResetRecoveryRequired) { return LocalResetProgressScreen( - initialResult: failure.result, + initialResult: cause.result, operation: widget.retryReset, ); } - final l10n = AppLocalizations.of(context)!; + final ko = Localizations.localeOf(context).languageCode == 'ko'; + // No intl/date initialization or stored preference is required for the + // minimum failure copy. Normal generated localization remains available. + final store = + cause is RestoreStoreUnavailable || + cause is LocalStorePreservationRequired || + cause is PairAuthorityUnavailable || + (_failure is StartupFailure && + (_failure as StartupFailure).allowsReset); + final restoring = cause is RestoreRecoveryRequired; + final cleanup = cause is StartupCleanupIncomplete; + final phase = cleanup + ? StartupStage.cleanup + : _failure is StartupFailure + ? (_failure as StartupFailure).stage + : widget.stage?.value; + final phaseLabel = switch (phase) { + StartupStage.locale => + ko ? '언어 정보 준비' : 'Preparing language information', + StartupStage.lifecycle => + ko + ? '설치 상태 확인 및 기존 정리' + : 'Checking installation and prior cleanup', + StartupStage.temporaryFiles => + ko ? '임시 복원 파일 정리' : 'Cleaning temporary restore files', + StartupStage.store => + ko ? '암호화된 로컬 데이터 확인' : 'Checking encrypted local data', + StartupStage.dependencies => + ko ? '앱 구성 요소 준비' : 'Preparing app components', + StartupStage.cleanup => + ko ? '시작 작업 자원 정리' : 'Closing startup resources', + null => null, + }; + final title = _busy + ? (_waiting + ? (ko + ? '시작 작업이 아직 끝나지 않았습니다.' + : 'Startup is still in progress.') + : (ko ? 'OnTime을 준비하고 있습니다.' : 'Preparing OnTime.')) + : restoring + ? (ko + ? '복원한 데이터의 정리가 남아 있습니다.' + : 'Restored data has pending cleanup.') + : cleanup + ? (ko + ? '이전 시작 작업을 정리해야 합니다.' + : 'Previous startup resources need cleanup.') + : store + ? (ko + ? '로컬 데이터를 안전하게 열 수 없습니다.' + : 'Local data cannot be opened safely.') + : (ko ? '앱 시작을 완료하지 못했습니다.' : 'Startup could not finish.'); + final body = restoring + ? (ko + ? '데이터 복원은 적용되었습니다. 남은 정리만 다시 시도합니다.' + : 'Data restoration was committed. Retry only the remaining cleanup.') + : store + ? (ko + ? '기존 데이터와 키를 새로 만들거나 자동 삭제하지 않습니다. 다시 시도하거나, 복구를 포기할 때만 명시적으로 초기화하세요.' + : 'Existing data and keys will not be replaced or automatically deleted. Retry, or explicitly reset only if you choose to give up recovery.') + : cleanup + ? (ko + ? '정리가 확인되기 전에는 새 시작 작업을 실행하지 않습니다.' + : 'A new startup cannot begin until cleanup is confirmed.') + : _canRetry + ? (ko + ? '완료된 단계와 남은 작업을 확인한 뒤 다시 시도합니다.' + : 'Retry after checking the completed and remaining startup work.') + : (ko ? '앱을 다시 열어주세요.' : 'Please reopen the app.'); return Scaffold( body: SafeArea( child: Center( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_busy) const CircularProgressIndicator(), - const SizedBox(height: 20), - Text( - _busy - ? (_waiting - ? l10n.startupWaitingBody - : l10n.startupLoadingBody) - : l10n.startupRecoveryTitle, - textAlign: TextAlign.center, - ), - if (!_busy) ...[ - const SizedBox(height: 12), - Text( - _canRetry - ? l10n.startupRecoveryBody - : l10n.restartRequiredBody, - textAlign: TextAlign.center, - ), - if (_canRetry) - FilledButton( - onPressed: _load, - child: Text(l10n.startupRetryAction), - ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_busy) const CircularProgressIndicator(), + const SizedBox(height: 20), + Text(title, textAlign: TextAlign.center), + if (phaseLabel != null) ...[ + const SizedBox(height: 12), + Text(phaseLabel, textAlign: TextAlign.center), + ], + if (!_busy) ...[ + const SizedBox(height: 12), + Text(body, textAlign: TextAlign.center), + if (_privacyCleanupPending) ...[ + const SizedBox(height: 12), + Text( + ko + ? '이전 임시 정보의 개인정보 정리도 완료되지 않았습니다.' + : 'Privacy cleanup of previous temporary information is also incomplete.', + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 16), + if (_canRetry) + FilledButton( + onPressed: _confirming ? null : _load, + child: Text(ko ? '다시 시도' : 'Try again'), + ), + if (store && + widget.recovery != null && + !_needsCleanup) + FilledButton( + onPressed: _confirming + ? null + : () => setState( + () => _restoreRequested = true, + ), + child: Text( + ko + ? 'OnTime 백업으로 복원' + : 'Restore an OnTime Backup', + ), + ), + if (store && + widget.beginReset != null && + !_needsCleanup) + TextButton( + onPressed: _confirming + ? null + : () => _confirmReset(context, ko), + child: Text( + ko ? '로컬 데이터 초기화' : 'Reset all local data', + textAlign: TextAlign.center, + ), + ), + ], ], - ], + ), ), ), ), diff --git a/lib/presentation/startup/screens/recovery_restore_screen.dart b/lib/presentation/startup/screens/recovery_restore_screen.dart new file mode 100644 index 00000000..e8a5b799 --- /dev/null +++ b/lib/presentation/startup/screens/recovery_restore_screen.dart @@ -0,0 +1,490 @@ +import 'package:on_time_front/presentation/backup/backup_recurring_time_review_screen.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/ports/backup_time_review_port.dart'; +import 'package:on_time_front/domain/ports/recovery_preclaim_cleanup_port.dart'; +import 'package:on_time_front/presentation/backup/backup_time_review_screen.dart'; +import 'package:on_time_front/presentation/backup/backup_processing_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:on_time_front/core/backup/backup_password.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/ports/recovery_restore_port.dart'; + +/// Pre-DI restore UI. The operation owner, not this State, owns native work. +class RecoveryRestoreScreen extends StatefulWidget { + const RecoveryRestoreScreen({ + super.key, + required this.port, + required this.onCancelled, + required this.onCompleted, + this.initialReceipt, + }); + final Future Function() port; + final VoidCallback onCancelled; + final VoidCallback onCompleted; + final BackupRestoreReceipt? initialReceipt; + @override + State createState() => _RecoveryRestoreScreenState(); +} + +class _RecoveryRestoreScreenState extends State { + bool _busy = false; + String? _error; + BackupRestoreReceipt? _receipt; + final _selections = {}; + RecoveryPreclaimCleanupPort? _preclaimPort; + int? _preclaimGeneration; + bool get _preclaimPending => _preclaimGeneration != null; + bool get ko => Localizations.localeOf(context).languageCode == 'ko'; + bool get _ownsIntent => _receipt?.followUpPending == true; + bool get _cleanupPending => _selections.isNotEmpty; + @override + void initState() { + super.initState(); + _receipt = widget.initialReceipt; + } + + Future _releaseSelections() async { + var complete = true; + for (final selected in List.of(_selections)) { + try { + await selected.dispose(); + _selections.remove(selected); + } catch (_) { + complete = false; + } + } + return complete; + } + + Future _retryCandidateCleanup() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + final complete = await _releaseSelections(); + if (!mounted) return; + setState(() { + _busy = false; + if (!complete) { + _error = ko + ? '후보 파일 정리를 완료하지 못했어요. 다시 시도해주세요.' + : 'Candidate cleanup did not finish. Retry cleanup.'; + } + }); + } + + Future _retryPreclaimCleanup() async { + final port = _preclaimPort, generation = _preclaimGeneration; + if (_busy || port == null || generation == null) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await port.retryPreclaimCleanup(generation); + if (!mounted) return; + setState(() { + _preclaimPort = null; + _preclaimGeneration = null; + }); + } catch (_) { + if (mounted) { + setState( + () => _error = ko + ? '알림 정리를 완료하지 못했어요. 같은 정리 작업을 다시 시도해주세요.' + : 'Alarm cleanup did not finish. Retry the same cleanup operation.', + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _select() async { + if (_busy || _ownsIntent || _cleanupPending || _preclaimPending) return; + setState(() { + _busy = true; + _error = null; + }); + RecoveryRestorePort? selectedPort; + try { + final password = await showDialog( + context: context, + builder: (_) => const RecoveryPasswordDialog(), + ); + if (!mounted || password == null) return; + final port = await widget.port(); + selectedPort = port; + if (!mounted) return; + final selected = port is BackupTimeReviewPort + ? await (port as BackupTimeReviewPort).selectForRestore(password) + : await port.preview(password); + if (selected != null) _selections.add(selected); + if (!mounted || selected == null) return; + BackupRestoreInput? candidate; + if (selected is BackupTimeReviewInput) { + candidate = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BackupTimeReviewScreen( + input: selected, + onReviewRecurrence: (current, issue) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BackupRecurringTimeReviewScreen( + input: current, + issue: issue, + ), + ), + ); + }, + ), + ), + ); + if (candidate != null) _selections.add(candidate); + } else if (selected is BackupRestoreInput) { + candidate = selected; + } else { + throw const DataOperationException(DataOperationFailure.invalidBackup); + } + if (!mounted || candidate == null) return; + final ready = candidate; + final preview = ready.preview; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text(ko ? '백업으로 교체할까요?' : 'Replace data with this backup?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ko + ? '백업 시점: ${preview.cutoffLiteral ?? preview.cutoff}\n앱: ${preview.sourceAppVersion}\n일정 ${preview.scheduleCount}개 · 템플릿 ${preview.templateCount}개 · 기본 준비 단계 ${preview.defaultPreparationStepCount}개\n\n현재 손상 데이터의 개수는 알 수 없습니다. 현재 데이터가 백업 내용으로 교체됩니다.' + : 'Backup cutoff: ${preview.cutoffLiteral ?? preview.cutoff}\nApp: ${preview.sourceAppVersion}\n${preview.scheduleCount} schedules · ${preview.templateCount} templates · ${preview.defaultPreparationStepCount} default preparation steps\n\nThe damaged original count is unknown. This backup will replace the current data.', + ), + BackupPreviewNotices(input: ready), + ], + ), + actions: [ + TextButton( + key: const Key('cancel-recovery-replacement'), + onPressed: () => Navigator.pop(context, false), + child: Text(ko ? '취소' : 'Cancel'), + ), + FilledButton( + key: const Key('confirm-recovery-replacement'), + onPressed: () => Navigator.pop(context, true), + child: Text(ko ? '교체' : 'Replace'), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + final result = await port.activate(ready); + if (mounted) setState(() => _receipt = result); + } on RecoveryOriginalAvailable { + if (mounted) { + setState( + () => _error = ko + ? '원본 데이터를 다시 열 수 있습니다. 돌아가서 정상 앱의 내 데이터에서 새 복원 확인을 진행하세요.' + : 'The original data can be opened again. Return to My Data and confirm a new restore there.', + ); + } + } catch (error) { + if (mounted) { + setState(() { + if (error is DataOperationException && + error.followUpPending && + error.generation != null) { + _preclaimGeneration = error.generation; + _preclaimPort = selectedPort is RecoveryPreclaimCleanupPort + ? selectedPort as RecoveryPreclaimCleanupPort + : null; + _receipt = null; + } + _error = + backupProcessingError(error, ko) ?? + (ko + ? '복구를 완료하지 못했습니다. 현재 단계에서 다시 시도하세요.' + : 'Recovery did not finish. Retry the current step.'); + }); + } + } finally { + final complete = await _releaseSelections(); + if (mounted) { + setState(() { + if (!complete) { + _error = ko + ? '후보 파일 정리가 남아 있습니다. 후보 정리를 다시 시도해주세요.' + : 'Candidate cleanup remains. Retry candidate cleanup.'; + } + _busy = false; + }); + } + } + } + + Future _resume() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final result = await (await widget.port()).resume(); + if (!mounted) return; + setState(() => _receipt = result); + if (result.disposition == BackupCommitDisposition.committed && + !result.followUpPending) { + widget.onCompleted(); + } + } catch (_) { + if (mounted) { + setState( + () => _error = ko + ? '같은 복구 작업의 상태를 확인할 수 없습니다. 잠시 후 다시 시도하세요.' + : 'The same recovery operation could not be checked. Try again later.', + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _abort() async { + if (_busy || + _receipt?.disposition != BackupCommitDisposition.notCommitted) { + return; + } + final yes = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(ko ? '이 복구를 중단할까요?' : 'Stop this recovery?'), + content: Text( + ko + ? '백업은 적용되지 않았습니다. 원본 데이터와 키를 유지하고 이 복구의 후보 파일과 키만 제거합니다.' + : 'The backup was not applied. Keep the original data and key and remove only this recovery candidate and its key.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(ko ? '돌아가기' : 'Back'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(ko ? '복구 중단' : 'Stop recovery'), + ), + ], + ), + ); + if (!mounted || yes != true) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final receipt = await (await widget.port()).abort(); + if (mounted) setState(() => _receipt = receipt); + } catch (_) { + if (mounted) { + setState( + () => _error = ko + ? '중단 정리가 끝나지 않았습니다. 같은 작업을 계속하세요.' + : 'Abort cleanup is incomplete. Continue the same operation.', + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + String get status { + if (_preclaimPending) { + return ko + ? '백업은 적용되지 않았습니다. 검토가 만료됐거나 복원 전 정리가 끝나지 않았습니다. 이미 시작한 알림 정리만 완료한 뒤 백업을 다시 선택해주세요.' + : 'The backup was not applied. Review expired or preparation for restoration did not finish. Complete the alarm cleanup already started, then select the backup again.'; + } + final receipt = _receipt; + if (receipt == null) { + return ko + ? '원본 데이터와 키를 보존한 채 백업을 검증합니다.' + : 'The backup is validated while preserving the original data and key.'; + } + return switch (receipt.disposition) { + BackupCommitDisposition.undetermined => + ko + ? '복원 적용 여부를 아직 확인할 수 없습니다. 새 복원이나 초기화를 시작하지 말고 같은 복구를 계속하세요.' + : 'The restore outcome is not confirmed. Continue this recovery without starting another restore or reset.', + BackupCommitDisposition.notCommitted => + ko + ? '교체가 적용되지 않았습니다. 원본을 유지하며 이 복구를 중단할 수 있습니다.' + : 'Replacement was not committed. You can stop this recovery while keeping the original.', + BackupCommitDisposition.committed => switch (receipt.recoveryFollowUp) { + RecoveryFollowUp.awaitingNewProcess => + ko + ? '백업이 적용되었습니다. 앱을 완전히 닫고 다시 열어 검증과 정리를 완료하세요. 기존 데이터와 키는 그때까지 보존됩니다.' + : 'The backup was applied. Fully close and reopen the app to verify and finish cleanup. The old data and key remain preserved until then.', + RecoveryFollowUp.verifyingPair => + ko + ? '복원된 데이터를 다시 검증해야 합니다.' + : 'The restored data requires verification.', + RecoveryFollowUp.cleanupPending => + ko + ? '복원은 적용되었으며 후속 정리가 남아 있습니다.' + : 'Restoration was applied and cleanup remains.', + null => ko ? '복원이 완료되었습니다.' : 'Restoration is complete.', + }, + }; + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy && !_ownsIntent && !_cleanupPending && !_preclaimPending, + child: Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + ko ? '백업으로 복구' : 'Recover from backup', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 20), + Text(status, textAlign: TextAlign.center), + const BackupProcessingPanel(), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, textAlign: TextAlign.center), + ], + const SizedBox(height: 20), + if (_busy) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text( + ko + ? '현재 작업이 끝날 때까지 기다려 주세요.' + : 'Please wait for the current operation to finish.', + ), + ], + if (_cleanupPending && !_busy) + OutlinedButton( + key: const Key('retry-recovery-candidate-cleanup'), + onPressed: _retryCandidateCleanup, + child: Text( + ko ? '후보 정리 다시 시도' : 'Retry candidate cleanup', + ), + ), + if (_preclaimPending) ...[ + FilledButton( + key: const Key('retry-recovery-preclaim-cleanup'), + onPressed: _busy || _preclaimPort == null + ? null + : _retryPreclaimCleanup, + child: Text(ko ? '알림 정리 다시 시도' : 'Retry alarm cleanup'), + ), + if (_preclaimPort == null) + Text( + ko + ? '같은 정리 작업을 열 수 없습니다. 앱을 완전히 닫고 다시 열어 상태를 확인해주세요.' + : 'The same cleanup operation is unavailable. Fully close and reopen the app to check its state.', + ), + ] else if (!_ownsIntent) ...[ + FilledButton( + key: const Key('select-recovery-backup'), + onPressed: _busy || _cleanupPending ? null : _select, + child: Text(ko ? '백업 파일 선택' : 'Choose backup file'), + ), + TextButton( + onPressed: _busy || _cleanupPending + ? null + : widget.onCancelled, + child: Text(ko ? '돌아가기' : 'Back'), + ), + ] else ...[ + FilledButton( + onPressed: _busy ? null : _resume, + child: Text(ko ? '복구 계속' : 'Continue recovery'), + ), + if (_receipt?.disposition == + BackupCommitDisposition.notCommitted) + TextButton( + onPressed: _busy ? null : _abort, + child: Text(ko ? '이 복구 중단' : 'Stop this recovery'), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); +} + +class RecoveryPasswordDialog extends StatefulWidget { + const RecoveryPasswordDialog({super.key}); + @override + State createState() => _RecoveryPasswordDialogState(); +} + +class _RecoveryPasswordDialogState extends State { + final text = TextEditingController(); + bool invalid = false; + @override + void dispose() { + text.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ko = Localizations.localeOf(context).languageCode == 'ko'; + return AlertDialog( + scrollable: true, + title: Text(ko ? '백업 비밀번호' : 'Backup password'), + content: TextField( + controller: text, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + autofillHints: null, + decoration: InputDecoration( + errorText: invalid + ? (ko + ? '백업에 사용한 비밀번호 전체를 입력하세요.' + : 'Enter the full backup password.') + : null, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(ko ? '취소' : 'Cancel'), + ), + FilledButton( + onPressed: () { + try { + Navigator.pop( + context, + BackupPassword.parse(text.text).normalized, + ); + } on FormatException { + setState(() => invalid = true); + } + }, + child: Text(ko ? '계속' : 'Continue'), + ), + ], + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 73a11066..743b08c1 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,10 @@ #include "generated_plugin_registrant.h" -#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); - file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 26c3d19c..9f8d1d42 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - file_selector_linux flutter_secure_storage_linux sqlcipher_flutter_libs ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4750c57f..1d7fe81b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,6 @@ import FlutterMacOS import Foundation -import file_selector_macos import flutter_local_notifications import flutter_secure_storage_darwin import package_info_plus @@ -14,7 +13,6 @@ import shared_preferences_foundation import sqlcipher_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/plans/pr-verified-checkpoint-2026-09-25/README.md b/plans/pr-verified-checkpoint-2026-09-25/README.md new file mode 100644 index 00000000..24b4e3f4 --- /dev/null +++ b/plans/pr-verified-checkpoint-2026-09-25/README.md @@ -0,0 +1,19 @@ +# 검증 완료 소스의 PR 전달 + +## 범위 +기존 PR #585의 head ee6c1f8b41fedbafee493791514026eaf669dcb5 이후, 전체 검증이 완료된 c8f09ce662271f9d127f38f89d8535ba4eac176b까지의 제품·테스트·빌드 구성과 관련 설계 문서를 전달한다. + +- 정상/손상 저장소 복원, 시작 복구, DB migration, bounded backup 처리 +- 시간대 및 반복 일정 보정, 저장 전 검토, 최근 일정 조회 +- 일정 이력 삭제, 상세 알림 설정, 백업/복원 실패 회귀 +- T04와 이후 작업, 별도 감사 계획 및 문답 게시 초안은 포함하지 않는다. + +## 전달 절차 +1. 진행 중인 작업 폴더와 분리된 checkout에서 이미 검증된 소스만 준비한다. +2. 원본 검증 manifest의 tracked 776개 입력을 SHA256으로 대조한다. Generated Dart 10개는 커밋하지 않는다. +3. 기존 PR #585를 선행 PR로 하는 후속 PR을 생성한다. +4. 새 head의 Flutter/Android workflow를 실행하고 실제 상태를 보고한다. +5. 병합과 미완료 이슈 종료는 이번 PR 게시 작업에 포함하지 않는다. + +## 검증과 한계 +verification.json은 원본 소스 시점의 결과이며 이 PR의 새 원격 CI 결과를 대신하지 않는다. 전체 SQLCipher 테스트 2090 통과, 조건부 제외 6, 실패 0; 분석 문제 0, 로컬 CI 6개 통과. 실제 기기 수용조건 및 U08 전체 화면 검증은 남아 있다. diff --git a/plans/pr-verified-checkpoint-2026-09-25/source-manifest.json b/plans/pr-verified-checkpoint-2026-09-25/source-manifest.json new file mode 100644 index 00000000..e4ae9ff3 --- /dev/null +++ b/plans/pr-verified-checkpoint-2026-09-25/source-manifest.json @@ -0,0 +1,3937 @@ +{ + "files": [ + { + "path": ".github/workflows/android-notification-contract.yml", + "sha256": "e2e36fc2445d2c08070f70fbbdfd68f3cc8a1c999d721f7b0179e5d70288ec27", + "member": ".github/workflows/android-notification-contract.yml" + }, + { + "path": ".github/workflows/android-play-closed.yml", + "sha256": "ff7adf598862265552060b57e947ed24cbaf26203d6e79592ac6e5dd5f452521", + "member": ".github/workflows/android-play-closed.yml" + }, + { + "path": ".github/workflows/android-play-internal.yml", + "sha256": "25f315de49c45f9ad27cd4c5b9375ddf6e08d9a5bbf962bbe76709c577ec4d81", + "member": ".github/workflows/android-play-internal.yml" + }, + { + "path": ".github/workflows/firebase-hosting-pull-request-widgetbook.yml", + "sha256": "3ad44d6d193c6bc0233f960897711f123ac674ed4b94fecb929436da6d57598e", + "member": ".github/workflows/firebase-hosting-pull-request-widgetbook.yml" + }, + { + "path": ".github/workflows/firebase_hosting-merge-widgetbook.yml", + "sha256": "c8ce930af130e8f6d99c1a029dea1991360acfe0100aac76a036b45459be1008", + "member": ".github/workflows/firebase_hosting-merge-widgetbook.yml" + }, + { + "path": ".github/workflows/flutter_test.yml", + "sha256": "6c22e4c3548bfc90982cced6704a1e917ec23ab65c2b8dca527d7a0ad4f18308", + "member": ".github/workflows/flutter_test.yml" + }, + { + "path": ".gitignore", + "sha256": "26633e0f31cc777c0576a7f58ea16850774d951e3b2724af9373a6a45dbed2d5", + "member": ".gitignore" + }, + { + "path": "analysis_options.yaml", + "sha256": "01e2b4f21325e3066d238ff5e3a5ae14ac35181749f3928b2e927611ed2862b4", + "member": "analysis_options.yaml" + }, + { + "path": "android/app/build.gradle", + "sha256": "c2bafd7ce6460bdaf58c683aa616e0aae8695941b03c8caf9fcab94a94bc1a9a", + "member": "android/app/build.gradle" + }, + { + "path": "android/app/src/main/AndroidManifest.xml", + "sha256": "6b16f0fbaf8febcc2f9647450e40ca498462e2142f279c90666f86f77f14f210", + "member": "android/app/src/main/AndroidManifest.xml" + }, + { + "path": "android/settings.gradle", + "sha256": "c0db3daff92f286e29e4a8b56d0cca0868863b4344ae43a8b65fe668487aa184", + "member": "android/settings.gradle" + }, + { + "path": "build.yaml", + "sha256": "ee54fb878126843f790d86476db411299c70ea1632126023ee04075499f8a956", + "member": "build.yaml" + }, + { + "path": "ios/Runner.xcodeproj/project.pbxproj", + "sha256": "004c615ea1e78256514dcb83fe3df5900a1a9732f4591bef8c6c0cb69c185e4b", + "member": "ios/Runner.xcodeproj/project.pbxproj" + }, + { + "path": "ios/Runner/AppDelegate.swift", + "sha256": "1980d6e397a70b1988e9a5d2a5d662f0c8bf6e6466f1516da95f0f6dc28a630d", + "member": "ios/Runner/AppDelegate.swift" + }, + { + "path": "ios/Runner/Info.plist", + "sha256": "f3ef9720f23fc5470ede5cf86a18d726d3fa0e21aa3c6132abc347ef8bb65604", + "member": "ios/Runner/Info.plist" + }, + { + "path": "lib/core/backup/backup_authenticated_time_review.dart", + "sha256": "4da23aa316302b0387f7b77c76ce55f5659f8d860199f50353f3d87e9e26c434", + "member": "lib/core/backup/backup_authenticated_time_review.dart" + }, + { + "path": "lib/core/backup/backup_content.dart", + "sha256": "8465d980d9ae7aa3c8fb77d0901f36641a926d9c012651a6a75da7f3dd092611", + "member": "lib/core/backup/backup_content.dart" + }, + { + "path": "lib/core/backup/backup_crypto.dart", + "sha256": "9d374484085341d5c8ddb55e2b3c20e01ad06470890339d3426fd81e9a140d9e", + "member": "lib/core/backup/backup_crypto.dart" + }, + { + "path": "lib/core/backup/backup_export_snapshot.dart", + "sha256": "58b4e25e3805adf56b28f2abb2127d63047fee0eada25b8e893875654155c9eb", + "member": "lib/core/backup/backup_export_snapshot.dart" + }, + { + "path": "lib/core/backup/backup_file_export_port.dart", + "sha256": "99130582773e5f2ec86263108a9e5f7c95af01e2468046562140f1315fe79b8a", + "member": "lib/core/backup/backup_file_export_port.dart" + }, + { + "path": "lib/core/backup/backup_file_import_port.dart", + "sha256": "01c21046e15aee450b20e7f92cea3453542e47d913035724fa3072274ef04580", + "member": "lib/core/backup/backup_file_import_port.dart" + }, + { + "path": "lib/core/backup/backup_ingestion_store.dart", + "sha256": "3e51708da494957f4a10994a7fd61570804445b4981205625bd235a148f06cfb", + "member": "lib/core/backup/backup_ingestion_store.dart" + }, + { + "path": "lib/core/backup/backup_json_reader.dart", + "sha256": "cd3a0001e05073ce5557dceea4f0b0de48047d4a42c259c53463515c8486cb6d", + "member": "lib/core/backup/backup_json_reader.dart" + }, + { + "path": "lib/core/backup/backup_limits.dart", + "sha256": "e5e8daff6680ad9567405690a6e4b8df755650ca346356d3d89707be6597c9c9", + "member": "lib/core/backup/backup_limits.dart" + }, + { + "path": "lib/core/backup/backup_password.dart", + "sha256": "042ad87c36c962c1f0aa4b53cfba013654605ddd1d0f5448b04b4324c58853d9", + "member": "lib/core/backup/backup_password.dart" + }, + { + "path": "lib/core/backup/backup_private_files.dart", + "sha256": "658810998b3ec0fe892e9a1168ee8905b6820c6103d332f780f7a00e6aefc33a", + "member": "lib/core/backup/backup_private_files.dart" + }, + { + "path": "lib/core/backup/backup_recurrence_scan.dart", + "sha256": "d5bceb4792d2fb148cb6f2d824fc3e2867cf4fe48e251c2fdea7c47addbced4f", + "member": "lib/core/backup/backup_recurrence_scan.dart" + }, + { + "path": "lib/core/backup/backup_recurring_time_review.dart", + "sha256": "d4d2327190dc5f19c8d9f0a7d31883197452e6e7215c805db8039fea9ceb4cfc", + "member": "lib/core/backup/backup_recurring_time_review.dart" + }, + { + "path": "lib/core/backup/backup_service.dart", + "sha256": "7d249ec1f8f0279f7eeb9bc458cb85006cb325901c935f4e1ffb71c909a5374c", + "member": "lib/core/backup/backup_service.dart" + }, + { + "path": "lib/core/backup/backup_time_conflict_world.dart", + "sha256": "7447218747c58695de511733a7fed409e8a993eb9b988a72eb422006b1ec49a5", + "member": "lib/core/backup/backup_time_conflict_world.dart" + }, + { + "path": "lib/core/backup/backup_validated_ingestion.dart", + "sha256": "abaffda7b542d917de795fd6b17f9273cf67a2943857a97df235bec8075f484a", + "member": "lib/core/backup/backup_validated_ingestion.dart" + }, + { + "path": "lib/core/backup/backup_value_validation.dart", + "sha256": "ef6f400c752fb9e03b92ff7bc735676e0584d7969fd2eef4abd740607d94a04e", + "member": "lib/core/backup/backup_value_validation.dart" + }, + { + "path": "lib/core/backup/recurring_backup_data.dart", + "sha256": "b3ca209e16a78363883352974ac71fbbfdfaf1434dba827d9621784863ca7acc", + "member": "lib/core/backup/recurring_backup_data.dart" + }, + { + "path": "lib/core/backup/restore_staging.dart", + "sha256": "9dd4cea5077696bd4a18bf3c694604844cdf81e10c7c8d22014e58233585d709", + "member": "lib/core/backup/restore_staging.dart" + }, + { + "path": "lib/core/constants/local_profile.dart", + "sha256": "c180bfc87721015db1352f8157678f3566c02046fa94c3f7468ea494baf2a99f", + "member": "lib/core/constants/local_profile.dart" + }, + { + "path": "lib/core/database/bootstrap_privacy_boundary.dart", + "sha256": "7e2b5071cbebb81dce56f27515423e71aaf4a8b4d2a31d3b8d2476d235af6658", + "member": "lib/core/database/bootstrap_privacy_boundary.dart" + }, + { + "path": "lib/core/database/database.dart", + "sha256": "efd66c4473021ed20de8692688cd95934b4885e7aab2252f81723eca41d13d33", + "member": "lib/core/database/database.dart" + }, + { + "path": "lib/core/database/database.g.dart", + "sha256": "521ec10815b94af28b63c319a097a0da16a3af34755853c95cda8ec0ebd18ac5", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/core/database/encrypted_database_guard.dart", + "sha256": "9d8861a7dc78bc64390c53a40c2cf001ce4f4e12a329e4a04e69c61d655baf0c", + "member": "lib/core/database/encrypted_database_guard.dart" + }, + { + "path": "lib/core/database/initial_store_guard.dart", + "sha256": "87c8711951cf4857404660eb59af36652bb76c586d4f29ada164db103f743b70", + "member": "lib/core/database/initial_store_guard.dart" + }, + { + "path": "lib/core/database/initial_store_guard_native.dart", + "sha256": "b782d3d5417ab3b2dd0c0c046c25e58e5e01a95dbbadab3a6225edda23c59985", + "member": "lib/core/database/initial_store_guard_native.dart" + }, + { + "path": "lib/core/database/initial_store_guard_web.dart", + "sha256": "53537b4c536de376f08b7404e3e7eecdb1125cce7a9c839a3a1c18e0bcbc4102", + "member": "lib/core/database/initial_store_guard_web.dart" + }, + { + "path": "lib/core/database/installation_key_store.dart", + "sha256": "97ea56d74d5a20a88174c118ab06715b85baa205cf55c801295983092af56c7c", + "member": "lib/core/database/installation_key_store.dart" + }, + { + "path": "lib/core/database/local_data_files.dart", + "sha256": "1877a136a7c8f774b1a532af647dfa2e13da135b43af3b0cf25629258e4d1ed1", + "member": "lib/core/database/local_data_files.dart" + }, + { + "path": "lib/core/database/local_data_files_native.dart", + "sha256": "323eea2a63be633ff287ca374b39ba25eb6f1828768d6298a08f2a2b9809c1a9", + "member": "lib/core/database/local_data_files_native.dart" + }, + { + "path": "lib/core/database/local_data_files_web.dart", + "sha256": "fefdeeafb21465fe5af18168607458ea4e1ea71191066f34016be7d48e31463c", + "member": "lib/core/database/local_data_files_web.dart" + }, + { + "path": "lib/core/database/local_data_lifecycle.dart", + "sha256": "df2cebec07c39546c1598d8b5abcf3345bedadac60efb3f745aba93c914786aa", + "member": "lib/core/database/local_data_lifecycle.dart" + }, + { + "path": "lib/core/database/local_data_operation_gate.dart", + "sha256": "c2beefdda67cd042d7d03ea52294af3d1df18ec0869f49f1ac42766db2fcb23c", + "member": "lib/core/database/local_data_operation_gate.dart" + }, + { + "path": "lib/core/database/local_data_reset_service.dart", + "sha256": "d0575f88cdf7301339e315d274a88632d9d693bad09f247e20fd103c1bf46fbe", + "member": "lib/core/database/local_data_reset_service.dart" + }, + { + "path": "lib/core/database/local_reset_actions.dart", + "sha256": "8a35d0953bf4d8a9d0277d9bca50567000e9d399c1ce285fe8c64990de2968f3", + "member": "lib/core/database/local_reset_actions.dart" + }, + { + "path": "lib/core/database/local_reset_protocol.dart", + "sha256": "02a56f3897080b7599e0265f8c15e7c425031467dc0e93025fc7348101ed9e69", + "member": "lib/core/database/local_reset_protocol.dart" + }, + { + "path": "lib/core/database/open_database.dart", + "sha256": "5147fbe63d9b43fefc9497deef8cdba06a7454c524df3cdde3b098fd79304a28", + "member": "lib/core/database/open_database.dart" + }, + { + "path": "lib/core/database/open_database_native.dart", + "sha256": "33a96865d461d2dad6a24e336199cd5612672372f88a101f0cc6a20e8856618c", + "member": "lib/core/database/open_database_native.dart" + }, + { + "path": "lib/core/database/open_database_unsupported.dart", + "sha256": "26de98197064ba45d8d574d639a1f85d01338a55e6bb0e5302ac394c6057db23", + "member": "lib/core/database/open_database_unsupported.dart" + }, + { + "path": "lib/core/database/open_database_web.dart", + "sha256": "ec0fe47260816155ac0d48d25e2a20d8a5a10684c64337c8bbba7572a759138f", + "member": "lib/core/database/open_database_web.dart" + }, + { + "path": "lib/core/database/recovery/encrypted_pair_database.dart", + "sha256": "9d985fae12b5b0994698a1b32af3fb739948f805bb5f2412d1ec2b1e7c63c0f1", + "member": "lib/core/database/recovery/encrypted_pair_database.dart" + }, + { + "path": "lib/core/database/recovery/native_process_identity.dart", + "sha256": "0fbe2809a4d05a1d2e52532b056271879593bdca2a3d469c020627e156522385", + "member": "lib/core/database/recovery/native_process_identity.dart" + }, + { + "path": "lib/core/database/recovery/pair_files.dart", + "sha256": "d9bc72c094b6f32fd6d7732b84182b9f87b56009014a0052fc1125c11686dacf", + "member": "lib/core/database/recovery/pair_files.dart" + }, + { + "path": "lib/core/database/recovery/pair_key_store.dart", + "sha256": "16c753046321bf6e97579c396681d8526edb219f3f668deb33f0d8eeaa89234d", + "member": "lib/core/database/recovery/pair_key_store.dart" + }, + { + "path": "lib/core/database/recovery/recovery_restore_service.dart", + "sha256": "700e478365358690bfdfd175c3344e6841084a1a8e3a40b4f0a869a94c5ed011", + "member": "lib/core/database/recovery/recovery_restore_service.dart" + }, + { + "path": "lib/core/database/recovery/store_pair.dart", + "sha256": "b614b47b36414775e86ebe823340475bb20eaa9b3a4aaf946388fbd88ced3058", + "member": "lib/core/database/recovery/store_pair.dart" + }, + { + "path": "lib/core/database/restore_delivery_cleanup.dart", + "sha256": "fb7899d854ddd63b7bdcbceacd15fe3916eb27cc844a7c681e1144c5bc49f689", + "member": "lib/core/database/restore_delivery_cleanup.dart" + }, + { + "path": "lib/core/database/restore_runtime_identity.dart", + "sha256": "c11bbaa843fc7d412a6bca563b01242c92651a9f80703e6a10d1ff893899024e", + "member": "lib/core/database/restore_runtime_identity.dart" + }, + { + "path": "lib/core/database/schedule_aggregate_schema.dart", + "sha256": "6fcc4e2dce5a78d0cb435777782573aac03eaf1152451875267cc9c4d7f9a312", + "member": "lib/core/database/schedule_aggregate_schema.dart" + }, + { + "path": "lib/core/database/schema_contract.dart", + "sha256": "39828d6aa63198d7b585765c39cd0268ddb4816224c1f0c49388aaff054b3096", + "member": "lib/core/database/schema_contract.dart" + }, + { + "path": "lib/core/database/schema_contracts.g.dart", + "sha256": "8ed8b25a2f859b108b1cbc026e1ea2039733933d1ec3dce3c02b80f0c237be6d", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/core/database/schema_contracts.schema.json", + "sha256": "48b5013f66ad768cdcef2724bff860b198f74abb1fbafb4ce56dc7359fdbc357", + "member": "lib/core/database/schema_contracts.schema.json" + }, + { + "path": "lib/core/database/sqlcipher_loader.dart", + "sha256": "72fc24e40ca2f3bf8ee763cfa724a70d82d7abdfc51e67115e2b46d822c675fe", + "member": "lib/core/database/sqlcipher_loader.dart" + }, + { + "path": "lib/core/di/di_setup.config.dart", + "sha256": "92d5bf3a05fc56006eb70d3036efc42f3ef8bd9644c72c936e64d5d6e195be31", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/core/di/di_setup.dart", + "sha256": "4d77f4936d65761d000b78b14536d0e1602629fc6a209bd67327c03b580060d6", + "member": "lib/core/di/di_setup.dart" + }, + { + "path": "lib/core/logging/app_logger.dart", + "sha256": "9e45b3a9b157ebd49d960550a5ca88ef71a6beecaccc18a51cb0f960178b6523", + "member": "lib/core/logging/app_logger.dart" + }, + { + "path": "lib/core/services/alarm_journal_store.dart", + "sha256": "eeb1dfc3ccc31410904300f1c354dc79ae20a7995122e34eab19b4bd98e96f93", + "member": "lib/core/services/alarm_journal_store.dart" + }, + { + "path": "lib/core/services/alarm_journal_store_native.dart", + "sha256": "7d8b052f35c81a7b553b5a074da92ba85e703c13d81fde7d6c1c8a1d953d4f67", + "member": "lib/core/services/alarm_journal_store_native.dart" + }, + { + "path": "lib/core/services/alarm_journal_store_web.dart", + "sha256": "d2bf8aef8f0b81c40e400c080751e64c8991b0db7dc4b7bf7f52f702a129da75", + "member": "lib/core/services/alarm_journal_store_web.dart" + }, + { + "path": "lib/core/services/alarm_operation_coordinator.dart", + "sha256": "299891cc3b3a0ebb45c887aba776636c5a43f3ec132d8c5db0d4e6fb7b3902ef", + "member": "lib/core/services/alarm_operation_coordinator.dart" + }, + { + "path": "lib/core/services/alarm_ownership_journal.dart", + "sha256": "d73e238cca1def0492ecd74ba8b677214cc04baed2d3de790d64773ae61a3a1f", + "member": "lib/core/services/alarm_ownership_journal.dart" + }, + { + "path": "lib/core/services/alarm_registration_cleanup.dart", + "sha256": "ebacb18786679faaeb78fef9055e6849c67fda272ec81fba2410c562434a34aa", + "member": "lib/core/services/alarm_registration_cleanup.dart" + }, + { + "path": "lib/core/services/alarm_scheduler_service.dart", + "sha256": "68cdd5deef016088c00fffb8777fb15a02b5a8a987798a639ac8abae15b214ab", + "member": "lib/core/services/alarm_scheduler_service.dart" + }, + { + "path": "lib/core/services/app_metadata_service.dart", + "sha256": "851293b1b21c791b4deca12f69095d6aafa615f0a1bb62dff93a9c9c82b80d6b", + "member": "lib/core/services/app_metadata_service.dart" + }, + { + "path": "lib/core/services/detailed_notification_preference_service.dart", + "sha256": "5f2bd9c253e2450dd3066dec751e319b29c441ace2f9617172c735027fcf4715", + "member": "lib/core/services/detailed_notification_preference_service.dart" + }, + { + "path": "lib/core/services/device_info_service/device_info_service_mobile.dart", + "sha256": "45d9651212b63ffd59b9d77a71ac415903cf6cad36d4648c8fdd4e79cb558f52", + "member": "lib/core/services/device_info_service/device_info_service_mobile.dart" + }, + { + "path": "lib/core/services/device_info_service/device_info_service_unsupported.dart", + "sha256": "664d1e6f5c2616163e1feec9798f5491b35020248b055386eb4069bfd1784491", + "member": "lib/core/services/device_info_service/device_info_service_unsupported.dart" + }, + { + "path": "lib/core/services/device_info_service/device_info_service_web.dart", + "sha256": "40aa5eb469a1417009a077dc492826ed7cae794181513786f88d660470814adc", + "member": "lib/core/services/device_info_service/device_info_service_web.dart" + }, + { + "path": "lib/core/services/device_info_service/shared.dart", + "sha256": "2c453d3dd57f7fe47c1c84b2a9fc5600162b3ce53186110e311b917ef3a9f954", + "member": "lib/core/services/device_info_service/shared.dart" + }, + { + "path": "lib/core/services/fallback_alarm_notification_service.dart", + "sha256": "3e15e7ce0fb5fed325ed530e00a484a22c43635daa987325c24b09e2ab64adaf", + "member": "lib/core/services/fallback_alarm_notification_service.dart" + }, + { + "path": "lib/core/services/js_interop_service.dart", + "sha256": "7d5889aef25ea3bb294dae2d9320ade32e94d6e09c602d05a40c3a29989eec09", + "member": "lib/core/services/js_interop_service.dart" + }, + { + "path": "lib/core/services/js_interop_service_stub.dart", + "sha256": "2b24f83a00bd811505159d508eea84e4333ffbfaebb62a3236dafef841c217b1", + "member": "lib/core/services/js_interop_service_stub.dart" + }, + { + "path": "lib/core/services/js_interop_service_web.dart", + "sha256": "586702f62aba8ce3a23cdba9634125dce631d76819b46766b4c5e7eadb246774", + "member": "lib/core/services/js_interop_service_web.dart" + }, + { + "path": "lib/core/services/local_time_zone_service.dart", + "sha256": "1f22ddf268a6c100bd1da7f92c4264b5b4fca7d9dd5b6c60a128837b8bf84b4c", + "member": "lib/core/services/local_time_zone_service.dart" + }, + { + "path": "lib/core/services/navigation_service.dart", + "sha256": "c03431988dc6503698773fee686d2d3587fb8ee16a75c3b0d65be12d39535146", + "member": "lib/core/services/navigation_service.dart" + }, + { + "path": "lib/core/services/notification_content.dart", + "sha256": "bb697b89ee6818ff84465cd5fa8fc36c68aa5a6f3688a34db040f0a2eb956609", + "member": "lib/core/services/notification_content.dart" + }, + { + "path": "lib/core/services/notification_routing.dart", + "sha256": "90904bf0523b572f1d25cbd32d85c1be34358d2c5bfce3b7928daa1e09347db0", + "member": "lib/core/services/notification_routing.dart" + }, + { + "path": "lib/core/services/notification_service.dart", + "sha256": "eba7163395b6b20d89c95b47f42022ca0b6abd9afb4d8fed0b211f35728399dd", + "member": "lib/core/services/notification_service.dart" + }, + { + "path": "lib/core/services/notification_tap_router.dart", + "sha256": "ce18aff896e522baff152ecba13909a58503405180f3047403a4e8f19833bcb8", + "member": "lib/core/services/notification_tap_router.dart" + }, + { + "path": "lib/core/services/runtime_privacy_migration.dart", + "sha256": "fdeea1be6ddb168d5dc8c5032a061a87608fb1a43049832dfba6fe807b209b45", + "member": "lib/core/services/runtime_privacy_migration.dart" + }, + { + "path": "lib/core/startup/startup_dependency_scope.dart", + "sha256": "a87421c7ca43bd627bf99f8ed41eecd93e6a523bd9ffa43e99986ab781533227", + "member": "lib/core/startup/startup_dependency_scope.dart" + }, + { + "path": "lib/core/startup/startup_failure.dart", + "sha256": "8948ba354f9b82840413eecb091b9211016200a8eeb6b37fa322cc824b8a63b0", + "member": "lib/core/startup/startup_failure.dart" + }, + { + "path": "lib/core/startup/subscription_cleanup.dart", + "sha256": "a42f8f3620cf055c750e9f76e71a11c3c78f3fa0e320e5cdb994a6efe3f1ffbf", + "member": "lib/core/startup/subscription_cleanup.dart" + }, + { + "path": "lib/core/time/civil_time_resolver.dart", + "sha256": "4a94c52039d9bd3589fcb36509e004219c1b5d1aa881c3695440eb02986c5b74", + "member": "lib/core/time/civil_time_resolver.dart" + }, + { + "path": "lib/core/time/device_civil_day.dart", + "sha256": "854714dcdd4cde1f469c696411b008a586c2d48dd7cc1fc21c1802520f4c2cc4", + "member": "lib/core/time/device_civil_day.dart" + }, + { + "path": "lib/core/time/recurring_time_correction_mapping.dart", + "sha256": "d33579029cae4780af484beaf6ce83793cbc8aa48ce6da2895e9e2fd0dee7863", + "member": "lib/core/time/recurring_time_correction_mapping.dart" + }, + { + "path": "lib/core/time/schedule_time_resolution.dart", + "sha256": "fb1636aba92f67a019822f6bd5cd805a6ddf37219917a0e9938615e1a9c90883", + "member": "lib/core/time/schedule_time_resolution.dart" + }, + { + "path": "lib/core/time/time_correction_conflicts.dart", + "sha256": "e43d76fdb54087a830a9ed060f220ccfe27acc34f6d8f4f02d4a0b064361b991", + "member": "lib/core/time/time_correction_conflicts.dart" + }, + { + "path": "lib/core/time/time_zone_rules.dart", + "sha256": "217fa34f7a2ddb53672f35388689e31d05af97e711e8fbe1b8bb03c25b1bb5a2", + "member": "lib/core/time/time_zone_rules.dart" + }, + { + "path": "lib/core/utils/json_converters/duration_json_converters.dart", + "sha256": "68f78dd8d23a85314a346f26801c91f4985ebffe047678be013c5e6a6f88b744", + "member": "lib/core/utils/json_converters/duration_json_converters.dart" + }, + { + "path": "lib/core/validation/local_input_limits.dart", + "sha256": "bc8633aed542be5ed18de090580faefa785858e7b6022ea61c3cbe2c7f46ff38", + "member": "lib/core/validation/local_input_limits.dart" + }, + { + "path": "lib/data/adapters/local_data_workflow_adapters.dart", + "sha256": "557a0525e3f7b78ea1b73af8017d3b289d37282a8674ab9ddd5356be4292f276", + "member": "lib/data/adapters/local_data_workflow_adapters.dart" + }, + { + "path": "lib/data/daos/place_dao.dart", + "sha256": "092548b6f057d4ab3b486ecf20046e5c7820fee3f2d560433692a58509183d3f", + "member": "lib/data/daos/place_dao.dart" + }, + { + "path": "lib/data/daos/place_dao.g.dart", + "sha256": "583ee827dcad95881d84282e6fd4c5a7010c0ab98107f8204b9b4284b36525b3", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/data/daos/preparation_schedule_dao.dart", + "sha256": "55e99d5f966a30f285c355c44c5868f7ab90de33a5c88853ea93343bbdde3f18", + "member": "lib/data/daos/preparation_schedule_dao.dart" + }, + { + "path": "lib/data/daos/preparation_schedule_dao.g.dart", + "sha256": "a8aefdc454116a8f6f9170cb48826fb207ef80a8f11f6eb4ec078d54d2554f7e", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/data/daos/preparation_template_dao.dart", + "sha256": "e8b708a02c84d6883f6b79778b9524913c1d110262978550d7118310c0d49ca8", + "member": "lib/data/daos/preparation_template_dao.dart" + }, + { + "path": "lib/data/daos/preparation_template_dao.g.dart", + "sha256": "e96cf33253760d030a7af8d7b610c289acdc1d4fc51601bf878cfd61e88675f7", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/data/daos/preparation_user_dao.dart", + "sha256": "a4017daa822bcdb8384f234c9d7f3e8c297ea290951236122ce2c5f843bf2e3f", + "member": "lib/data/daos/preparation_user_dao.dart" + }, + { + "path": "lib/data/daos/preparation_user_dao.g.dart", + "sha256": "6356ee638fefa59d01966c82dcf56bac64f3bf4429f1a75a5506fd11505ae0bd", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/data/daos/schedule_dao.dart", + "sha256": "dc1da9cbfa271e3953a26decee5eedbd108e35b2b4f1528b26c78a5e858fa8e2", + "member": "lib/data/daos/schedule_dao.dart" + }, + { + "path": "lib/data/daos/schedule_dao.g.dart", + "sha256": "595e17d0ff8784cd406553a883d86153f804ddd0dc305f359c6b973321caf2cf", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/data/daos/schedule_owned_content_cleanup.dart", + "sha256": "cf15ecc1f837319a60f977ea750cc64c9d22183d8ed6a66cc479b74d37821099", + "member": "lib/data/daos/schedule_owned_content_cleanup.dart" + }, + { + "path": "lib/data/daos/user_dao.dart", + "sha256": "37f08ff913035b2118b8d183f29119ca078af0f0e371bd44302200a1cad37ca6", + "member": "lib/data/daos/user_dao.dart" + }, + { + "path": "lib/data/daos/user_dao.g.dart", + "sha256": "4f482757f6bc650117ec8419561dfe28c9b0e255425a449f3a191f3f4c4a52c0", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/data/data_sources/alarm_registry_local_data_source.dart", + "sha256": "410ba53d780e6225c74b1c164c2d4729b2997ade4fb0368c1b3148a6aadee0f1", + "member": "lib/data/data_sources/alarm_registry_local_data_source.dart" + }, + { + "path": "lib/data/data_sources/early_start_session_local_data_source.dart", + "sha256": "96126859a6138c95aba4defe075a689b5499413a8d89109fdaf4e9cbfc4d716f", + "member": "lib/data/data_sources/early_start_session_local_data_source.dart" + }, + { + "path": "lib/data/data_sources/preparation_local_data_source.dart", + "sha256": "cdfe40002812d5b085876b4688c3cffa51d922134b0270528e980227f1824773", + "member": "lib/data/data_sources/preparation_local_data_source.dart" + }, + { + "path": "lib/data/data_sources/preparation_with_time_local_data_source.dart", + "sha256": "415e058cf3d20272af73b0314e74cc8a4fc002bf491be1dc6a0562e73babe3d7", + "member": "lib/data/data_sources/preparation_with_time_local_data_source.dart" + }, + { + "path": "lib/data/data_sources/schedule_local_data_source.dart", + "sha256": "e6316ed0995eab1a3b5c1b25211f5295e1ac67515a295220b7317e0c69080b57", + "member": "lib/data/data_sources/schedule_local_data_source.dart" + }, + { + "path": "lib/data/mappers/domain_persistence_mappers.dart", + "sha256": "442dfc1b41a9868598c2d42b1ecec37242f11b3a57c1508c3b9a0548f96d56ea", + "member": "lib/data/mappers/domain_persistence_mappers.dart" + }, + { + "path": "lib/data/mappers/recurrence_codec.dart", + "sha256": "25ea0c3fa83b51271126ddc5d2d3660fe50cf2becba393d4372a8bc0aba7e34e", + "member": "lib/data/mappers/recurrence_codec.dart" + }, + { + "path": "lib/data/models/scheduled_alarm_record_model.dart", + "sha256": "1366c69727bae27a06f9887939ea7e7ab5c6a5eed432513ec6ecb92d765c4233", + "member": "lib/data/models/scheduled_alarm_record_model.dart" + }, + { + "path": "lib/data/repositories/alarm_registry_repository_impl.dart", + "sha256": "ac5fb3c920ac8185dd20841059b64ffe5b6d5f84ae846d73b7a6eac3fd8e567c", + "member": "lib/data/repositories/alarm_registry_repository_impl.dart" + }, + { + "path": "lib/data/repositories/alarm_repository_impl.dart", + "sha256": "aa4bf65d3b99a630d5efdb23da7e96b692e1a8b5846da839a1164bb52e380b28", + "member": "lib/data/repositories/alarm_repository_impl.dart" + }, + { + "path": "lib/data/repositories/default_preferences_repository_impl.dart", + "sha256": "02d4156f871825e8494bf99630e201d16612472b7b7dc92df14f0d9fa18d31ba", + "member": "lib/data/repositories/default_preferences_repository_impl.dart" + }, + { + "path": "lib/data/repositories/early_start_session_repository_impl.dart", + "sha256": "ebf3e50cbceab742ba66ca859f2b80d34bcecd3073163877f69f9cdfcc9ff2d2", + "member": "lib/data/repositories/early_start_session_repository_impl.dart" + }, + { + "path": "lib/data/repositories/nearest_schedule_query_repository_impl.dart", + "sha256": "2ebf5df72a573b09386a712738ebd7fa99ba99a5d97128a4eb751d5d4f87589c", + "member": "lib/data/repositories/nearest_schedule_query_repository_impl.dart" + }, + { + "path": "lib/data/repositories/preparation_repository_impl.dart", + "sha256": "9bd567431a9dcd1ab1f183da081e0598a07e7cd00df3a205a21662b1b2b6c610", + "member": "lib/data/repositories/preparation_repository_impl.dart" + }, + { + "path": "lib/data/repositories/preparation_template_repository_impl.dart", + "sha256": "e498474de4aa366a88b67a3da662ff6797e0bcd2b0f19292f771ab679a583049", + "member": "lib/data/repositories/preparation_template_repository_impl.dart" + }, + { + "path": "lib/data/repositories/recurring_schedule_repository_impl.dart", + "sha256": "7ab999eaf2782bb17c876070df47ab46604953d1186c8f991f872bc1eb490082", + "member": "lib/data/repositories/recurring_schedule_repository_impl.dart" + }, + { + "path": "lib/data/repositories/recurring_time_correction_repository_impl.dart", + "sha256": "266d526f93e18e12be419264ba03d598a595305f3e7a43d98e6ab2f7f1494a7a", + "member": "lib/data/repositories/recurring_time_correction_repository_impl.dart" + }, + { + "path": "lib/data/repositories/schedule_aggregate_repository_impl.dart", + "sha256": "f674fd1da5504596a7217b68166317c5b9a33e5d77178ac6d9f3baeb1c2c2f17", + "member": "lib/data/repositories/schedule_aggregate_repository_impl.dart" + }, + { + "path": "lib/data/repositories/schedule_repository_impl.dart", + "sha256": "57142cb5bba915204b9f0146f6a853a9dae49904f7ea1d2ae72062bc8870f490", + "member": "lib/data/repositories/schedule_repository_impl.dart" + }, + { + "path": "lib/data/repositories/schedule_time_correction_repository_impl.dart", + "sha256": "d02553a68d3f427c9377172e711f114fb96f4ed93f21b5df383d377b5191bb72", + "member": "lib/data/repositories/schedule_time_correction_repository_impl.dart" + }, + { + "path": "lib/data/repositories/time_correction_conflict_reader.dart", + "sha256": "2a344eff2522f28cb5a62747198003c73c3ddc7e02612f3042563dff097cc881", + "member": "lib/data/repositories/time_correction_conflict_reader.dart" + }, + { + "path": "lib/data/repositories/timed_preparation_repository_impl.dart", + "sha256": "33ad13ae5fb72eefb8bad6e177adffc3887f76f44e89d9ec0010fcd50df528bc", + "member": "lib/data/repositories/timed_preparation_repository_impl.dart" + }, + { + "path": "lib/data/repositories/user_repository_impl.dart", + "sha256": "1713cc38e2feb1e53eb2335dd96a6318bae58ec7495bc45f9de2b21d551fc66c", + "member": "lib/data/repositories/user_repository_impl.dart" + }, + { + "path": "lib/data/tables/places_table.dart", + "sha256": "4faa94d0ea174eb72fb6af97b4f83bb6086c22d4bd99b5eb96b8f087d799a933", + "member": "lib/data/tables/places_table.dart" + }, + { + "path": "lib/data/tables/preparation_schedule_table.dart", + "sha256": "4e93927beeefd4d4431a84bcf31b6a9fdba98209cef25853b1d1b4f9104d8614", + "member": "lib/data/tables/preparation_schedule_table.dart" + }, + { + "path": "lib/data/tables/preparation_template_step_table.dart", + "sha256": "d7554446891571289182708cb71cc28582d199b4149025001b6ba79b2b369428", + "member": "lib/data/tables/preparation_template_step_table.dart" + }, + { + "path": "lib/data/tables/preparation_template_table.dart", + "sha256": "42a8943a79f459be733883655178bd2e7c3c2409b1ade2ff6a788b72d76d57f9", + "member": "lib/data/tables/preparation_template_table.dart" + }, + { + "path": "lib/data/tables/preparation_user_table.dart", + "sha256": "df066df5176f094017b2a33b86f520110d3aa887dd00441079cced8cfe09dc03", + "member": "lib/data/tables/preparation_user_table.dart" + }, + { + "path": "lib/data/tables/recurring_schedule_tables.dart", + "sha256": "d1e76b7157a0ffbfef018ccebb28ffd1848d7cf56cebfa94a69651ea6574e3bb", + "member": "lib/data/tables/recurring_schedule_tables.dart" + }, + { + "path": "lib/data/tables/schedule_with_place_model.dart", + "sha256": "8077c1296ec5bd46a9f878b7fe62d780ec3999ee6ebc8e5110c5d1aa3eb2e1dc", + "member": "lib/data/tables/schedule_with_place_model.dart" + }, + { + "path": "lib/data/tables/schedules_table.dart", + "sha256": "7cdf7dd7742037fe35fea9fe3a3a4050aa94e196d81a9d5673fa024b676733ce", + "member": "lib/data/tables/schedules_table.dart" + }, + { + "path": "lib/data/tables/user_table.dart", + "sha256": "012cca4d532c5a16b4851e821be47e7edb3d224cea20d662ec31f75ce85aff09", + "member": "lib/data/tables/user_table.dart" + }, + { + "path": "lib/domain/entities/adjacent_schedules_with_preparation_entity.dart", + "sha256": "e8e3103da76a609dff08908e492056c70cfbc32a5bc20640e01ba8dc6562a831", + "member": "lib/domain/entities/adjacent_schedules_with_preparation_entity.dart" + }, + { + "path": "lib/domain/entities/alarm_delivery_policy.dart", + "sha256": "28e99dac2867c63feb71ae2256695f165e4b8a8c067168223a93dbf8d00f747f", + "member": "lib/domain/entities/alarm_delivery_policy.dart" + }, + { + "path": "lib/domain/entities/alarm_entities.dart", + "sha256": "1aa1c6972c20a9610d83f51e4ca0f04f1853efdd6bb63f523ceae596a11b33ec", + "member": "lib/domain/entities/alarm_entities.dart" + }, + { + "path": "lib/domain/entities/backup_operation.dart", + "sha256": "49ea4205cbe76f258de06d6e88d21101aab21eda1aa0afa93efe381515cec23c", + "member": "lib/domain/entities/backup_operation.dart" + }, + { + "path": "lib/domain/entities/backup_processing.dart", + "sha256": "48e1ad1719de43fa2921b76e319343b1ac65a0d3853bbc89d106a7040b415a79", + "member": "lib/domain/entities/backup_processing.dart" + }, + { + "path": "lib/domain/entities/backup_recurring_time_review.dart", + "sha256": "afe96ec68f3a661fb5e016c181c1227a3a5087c381b4998a91a33a35560ec57c", + "member": "lib/domain/entities/backup_recurring_time_review.dart" + }, + { + "path": "lib/domain/entities/backup_restore_selection.dart", + "sha256": "6454e43860705e16757ce273b9cfea595278e02cd1110c279fb8d7e6487e24da", + "member": "lib/domain/entities/backup_restore_selection.dart" + }, + { + "path": "lib/domain/entities/backup_time_review.dart", + "sha256": "098e4e7b454e2a50001176c29418192bf569ab8c8d52e0b0df6db2fce172304f", + "member": "lib/domain/entities/backup_time_review.dart" + }, + { + "path": "lib/domain/entities/civil_date_time.dart", + "sha256": "cd8639291bebdfbb79e60cff3f91f7689aefd1502c973120acbdcae6f6e54dbf", + "member": "lib/domain/entities/civil_date_time.dart" + }, + { + "path": "lib/domain/entities/civil_time_occurrence.dart", + "sha256": "60f94db6c7e5fd9c1da94b803ac02d554bc08139fe93250b5ecb576d39165b73", + "member": "lib/domain/entities/civil_time_occurrence.dart" + }, + { + "path": "lib/domain/entities/default_preferences.dart", + "sha256": "6efbe43b9c4c0ba7b58a57a23143af1f7d94425974bb9b6aba3772e5f187bb59", + "member": "lib/domain/entities/default_preferences.dart" + }, + { + "path": "lib/domain/entities/delivery_observation.dart", + "sha256": "2f0623716afe22b99e1d6026ab5c82019d054569b032b77c7abfc7af89d6de5f", + "member": "lib/domain/entities/delivery_observation.dart" + }, + { + "path": "lib/domain/entities/early_start_session_entity.dart", + "sha256": "a114fea000555c64efd986ed20ba1905b234322809a812eb85dd7891cca17ad3", + "member": "lib/domain/entities/early_start_session_entity.dart" + }, + { + "path": "lib/domain/entities/local_reset_result.dart", + "sha256": "a2c87269003a16bad04ed3ce5f366e319e435c5ad2bb214376e6645f69282bd0", + "member": "lib/domain/entities/local_reset_result.dart" + }, + { + "path": "lib/domain/entities/nearest_schedule_query.dart", + "sha256": "1b0bd4dad1d23e449e8c661688b4d413f10776bae5cfbbcdf3338f8895fd9caf", + "member": "lib/domain/entities/nearest_schedule_query.dart" + }, + { + "path": "lib/domain/entities/notification_route_payload.dart", + "sha256": "fb197806fb4ff55e0818a8d12155acdbaa7adecc03c032054de8dea564b5f14b", + "member": "lib/domain/entities/notification_route_payload.dart" + }, + { + "path": "lib/domain/entities/place_entity.dart", + "sha256": "de73d57dde841e9c1a995d704d9cd21620a7319c23aef79ba8f81794a35bec3c", + "member": "lib/domain/entities/place_entity.dart" + }, + { + "path": "lib/domain/entities/preparation_action_event_entity.dart", + "sha256": "cd19303623aa39680c7255cbf3ec2ba9e3643f58c95b6aebe985ca6401e821c3", + "member": "lib/domain/entities/preparation_action_event_entity.dart" + }, + { + "path": "lib/domain/entities/preparation_entity.dart", + "sha256": "d502cc73ddbefcd188948cee3ccf99f30b078a147457cf777a6326d96d98bae0", + "member": "lib/domain/entities/preparation_entity.dart" + }, + { + "path": "lib/domain/entities/preparation_snapshot_validation.dart", + "sha256": "a71cc6843d1cd6fef8c729ff10af52331b69378585111d58a3db63834c312dda", + "member": "lib/domain/entities/preparation_snapshot_validation.dart" + }, + { + "path": "lib/domain/entities/preparation_step_entity.dart", + "sha256": "70d16144e4700a42fa50479c64bc0ae616f8e3c3cad271354845ed2159af36f2", + "member": "lib/domain/entities/preparation_step_entity.dart" + }, + { + "path": "lib/domain/entities/preparation_step_with_time_entity.dart", + "sha256": "bc07d5132050cf27cca687a46b4faef07898624bd9cf4828402fa77937e68354", + "member": "lib/domain/entities/preparation_step_with_time_entity.dart" + }, + { + "path": "lib/domain/entities/preparation_template_entity.dart", + "sha256": "e2fa8c32836461e08fdcf90b786e0ccd692053a1eff9b951b091146aa07a77be", + "member": "lib/domain/entities/preparation_template_entity.dart" + }, + { + "path": "lib/domain/entities/preparation_with_time_entity.dart", + "sha256": "73431e54375f34b457ce8796ba842cb21b4895fb0a19c5baca198e05ba45c31e", + "member": "lib/domain/entities/preparation_with_time_entity.dart" + }, + { + "path": "lib/domain/entities/schedule_deletion.dart", + "sha256": "37e00513243e151f52ef33392e8c4d619e708daf7da390bbe3fb6cc2d95ffcae", + "member": "lib/domain/entities/schedule_deletion.dart" + }, + { + "path": "lib/domain/entities/schedule_entity.dart", + "sha256": "d5bee952cb796326699c3a52f487192d6ae24d434d8074e2a4e2e733923e2a90", + "member": "lib/domain/entities/schedule_entity.dart" + }, + { + "path": "lib/domain/entities/schedule_not_found.dart", + "sha256": "7d2f02cf9233919f56f2fb52901a9e32974c729f5c71ad514bd7feeabf65155e", + "member": "lib/domain/entities/schedule_not_found.dart" + }, + { + "path": "lib/domain/entities/schedule_notification_status.dart", + "sha256": "93e994a46aab2768b33eb6d9f132ca9e20f1fef7f521b860ad1809abc7dcbcc8", + "member": "lib/domain/entities/schedule_notification_status.dart" + }, + { + "path": "lib/domain/entities/schedule_preparation_mode.dart", + "sha256": "0bc04629e912e65ef01111fda8d6652d5dc9c72aa1cbab4067ea7fce13f27516", + "member": "lib/domain/entities/schedule_preparation_mode.dart" + }, + { + "path": "lib/domain/entities/schedule_save.dart", + "sha256": "9fd020ed04d81f3fc8f552cf9b9437f27bd6f74159cb038bbc1590909dde3b76", + "member": "lib/domain/entities/schedule_save.dart" + }, + { + "path": "lib/domain/entities/schedule_start_rejected.dart", + "sha256": "87b12570f7e622fd4490d7b46b9e9d2d24d0ce6ed192864888280ce614b0ecbc", + "member": "lib/domain/entities/schedule_start_rejected.dart" + }, + { + "path": "lib/domain/entities/schedule_time_correction.dart", + "sha256": "72a3c5612f6bf7e07a9336ad69f88d2dfd1ad5525103282e29a70b264f8246f7", + "member": "lib/domain/entities/schedule_time_correction.dart" + }, + { + "path": "lib/domain/entities/schedule_time_resolution.dart", + "sha256": "26635aa80f3607f1012b392511f3f2a86986be96e6fa4b4688b3c87f43470bb1", + "member": "lib/domain/entities/schedule_time_resolution.dart" + }, + { + "path": "lib/domain/entities/schedule_with_preparation_entity.dart", + "sha256": "c2b590548f96da567dd9c926e265dc0a860da1b27f4906e1f5c046020c4ae30a", + "member": "lib/domain/entities/schedule_with_preparation_entity.dart" + }, + { + "path": "lib/domain/entities/scheduled_notification_content.dart", + "sha256": "f2f6d2fcf72c9f10b42b659589c7cae641c357b14135e27b13ad96a8920ae95e", + "member": "lib/domain/entities/scheduled_notification_content.dart" + }, + { + "path": "lib/domain/entities/time_correction_conflict.dart", + "sha256": "5877a0e7914af3a63dbcfe60f62e37f120a0e9ac554de5d73fbd5dec8783dd94", + "member": "lib/domain/entities/time_correction_conflict.dart" + }, + { + "path": "lib/domain/entities/timed_preparation_snapshot_entity.dart", + "sha256": "29d44ca08aa0846fe225b458280856f09de176221bdef42c01f025abb874defa", + "member": "lib/domain/entities/timed_preparation_snapshot_entity.dart" + }, + { + "path": "lib/domain/entities/user_entity.dart", + "sha256": "c072da5b1aee09a57e3175003205804f79e7d91c555541fa04d9565f25cec5a9", + "member": "lib/domain/entities/user_entity.dart" + }, + { + "path": "lib/domain/entities/user_entity.freezed.dart", + "sha256": "18691f90831cde97833c0fd4f9d79336464e9c32f8840cebb17dd101f8c3133a", + "retention": "ignored generated: hash only" + }, + { + "path": "lib/domain/ports/backup_file_export_port.dart", + "sha256": "17f34296b0e60a9d138a35f18810a5567a6025ce2575213e314decc24cd08cfb", + "member": "lib/domain/ports/backup_file_export_port.dart" + }, + { + "path": "lib/domain/ports/backup_file_import_port.dart", + "sha256": "82340e356f7d1219e098ff9e4a2a0186aa49669fff9dc06e36d438028876c4ec", + "member": "lib/domain/ports/backup_file_import_port.dart" + }, + { + "path": "lib/domain/ports/backup_time_review_port.dart", + "sha256": "1dd8fed7b860ecc772835babe079d6651206e5ead8a4c28a80ed75c2bc737c7a", + "member": "lib/domain/ports/backup_time_review_port.dart" + }, + { + "path": "lib/domain/ports/local_data_ports.dart", + "sha256": "7b461658630fc11d72546bad76bce2a44ae53d1e1fe052138ac2c32a5f106ecb", + "member": "lib/domain/ports/local_data_ports.dart" + }, + { + "path": "lib/domain/ports/recovery_preclaim_cleanup_port.dart", + "sha256": "8fbdb5a24715477b95f323bd99ab7b82c35be0ef5926cb8df71aedf2bf4beb47", + "member": "lib/domain/ports/recovery_preclaim_cleanup_port.dart" + }, + { + "path": "lib/domain/ports/recovery_restore_port.dart", + "sha256": "a5782cabc4eab660d0aa3a9ad016405a46db7d626ae805e9239a3ace362c811b", + "member": "lib/domain/ports/recovery_restore_port.dart" + }, + { + "path": "lib/domain/recurrence/recurrence_engine.dart", + "sha256": "88d10b7be5383f65efc0486a0a5d35ee4fc1344d4355436a8bc35ae5d350ac08", + "member": "lib/domain/recurrence/recurrence_engine.dart" + }, + { + "path": "lib/domain/recurrence/recurrence_reference_policy.dart", + "sha256": "af40ed46064dc8b7956d73a02815871e8ecb2478a2b3a1f60491e2e91e23d1a0", + "member": "lib/domain/recurrence/recurrence_reference_policy.dart" + }, + { + "path": "lib/domain/recurrence/recurrence_rule.dart", + "sha256": "53b02120fcb5971277e6294c0b0a33acf4164609140d11e3cc5036f508b72c00", + "member": "lib/domain/recurrence/recurrence_rule.dart" + }, + { + "path": "lib/domain/recurrence/recurring_schedule.dart", + "sha256": "1132d79bc77b0ea177fc9df8695eee2de7d7478649a72e0a4db04fc4fc5ec450", + "member": "lib/domain/recurrence/recurring_schedule.dart" + }, + { + "path": "lib/domain/recurrence/recurring_time_correction.dart", + "sha256": "328b508631a8c6d76cec9aa990e2ec2b2c0e69c9e44d121640e280002dc2252e", + "member": "lib/domain/recurrence/recurring_time_correction.dart" + }, + { + "path": "lib/domain/repositories/alarm_registry_repository.dart", + "sha256": "0798b435f240573dd06c779e23b6f9d07a6c43ffc612edc2b4728a0919224a6c", + "member": "lib/domain/repositories/alarm_registry_repository.dart" + }, + { + "path": "lib/domain/repositories/alarm_repository.dart", + "sha256": "101599fff6ff6ba21629f953b694edd40a5ec16cb69bd6b5a3e68e665cbe1dc0", + "member": "lib/domain/repositories/alarm_repository.dart" + }, + { + "path": "lib/domain/repositories/default_preferences_repository.dart", + "sha256": "71f92769416031a842c66067cde65cea05ac8fd26015003ef3ac538557e2b073", + "member": "lib/domain/repositories/default_preferences_repository.dart" + }, + { + "path": "lib/domain/repositories/early_start_session_repository.dart", + "sha256": "3a099407358b2071a300e8d23423d7605e8392ad0b9690a633edbbfcac797eef", + "member": "lib/domain/repositories/early_start_session_repository.dart" + }, + { + "path": "lib/domain/repositories/nearest_schedule_query_repository.dart", + "sha256": "fd86054503895ec9bda9f62ea71c2135edf100ebd8a4228bfcf4c005cec6f26b", + "member": "lib/domain/repositories/nearest_schedule_query_repository.dart" + }, + { + "path": "lib/domain/repositories/preparation_repository.dart", + "sha256": "0ac5a1eaefe0a49e8a0666e4f354adf083bd7c1dda34c9d0ec79731005e83015", + "member": "lib/domain/repositories/preparation_repository.dart" + }, + { + "path": "lib/domain/repositories/preparation_template_repository.dart", + "sha256": "7f56dc6f0bd337036aa94850ab21edfc459923bd3d0fe80af9c2649d6406c971", + "member": "lib/domain/repositories/preparation_template_repository.dart" + }, + { + "path": "lib/domain/repositories/recurring_schedule_repository.dart", + "sha256": "b3f54ddb09e1981f93e9a91e0a8b37d4c929a6b44d1b0c908480f57ff36b7f20", + "member": "lib/domain/repositories/recurring_schedule_repository.dart" + }, + { + "path": "lib/domain/repositories/recurring_time_correction_repository.dart", + "sha256": "7ba189c0d33afef2aa1cfa2c2b852c64c9f407a42540f41e83fde822a12b907f", + "member": "lib/domain/repositories/recurring_time_correction_repository.dart" + }, + { + "path": "lib/domain/repositories/schedule_aggregate_repository.dart", + "sha256": "e4210056b55c4d64a3068e585b5e5445a3e0b6aaa410174310447eb0e563311c", + "member": "lib/domain/repositories/schedule_aggregate_repository.dart" + }, + { + "path": "lib/domain/repositories/schedule_repository.dart", + "sha256": "6e29168e7a9b9347fcb3b5433f8cbd5394b1ddf7ce8c85821e273606669e46a5", + "member": "lib/domain/repositories/schedule_repository.dart" + }, + { + "path": "lib/domain/repositories/schedule_time_correction_repository.dart", + "sha256": "2419098ecdcacb7955058c602d4637e796dc854c2531426871a63943aa231192", + "member": "lib/domain/repositories/schedule_time_correction_repository.dart" + }, + { + "path": "lib/domain/repositories/timed_preparation_repository.dart", + "sha256": "0bde910c7a0abe4a2cd50d441d22c000748a42eeeeae4489da732f9aceb7afe2", + "member": "lib/domain/repositories/timed_preparation_repository.dart" + }, + { + "path": "lib/domain/repositories/user_repository.dart", + "sha256": "65b28a130e4675d94b522cd32ca8ddc2b0146163ea35f8adb45d468e94bca935", + "member": "lib/domain/repositories/user_repository.dart" + }, + { + "path": "lib/domain/use-cases/cancel_all_alarms_use_case.dart", + "sha256": "bdbdc24b3555a6ff5cb39fa145c8c18a0996f6450bf2d5d76294774a61652d2f", + "member": "lib/domain/use-cases/cancel_all_alarms_use_case.dart" + }, + { + "path": "lib/domain/use-cases/cancel_schedule_alarm_use_case.dart", + "sha256": "910edcb21f162d0720c4bd3ee78b40ef05abb571b02eeae7ae4a0e4536bc4939", + "member": "lib/domain/use-cases/cancel_schedule_alarm_use_case.dart" + }, + { + "path": "lib/domain/use-cases/clear_early_start_session_use_case.dart", + "sha256": "e03b72e99c2ab379ba16d7db5d6247b39ada25a2e615a1daf5608ac62e781acf", + "member": "lib/domain/use-cases/clear_early_start_session_use_case.dart" + }, + { + "path": "lib/domain/use-cases/clear_timed_preparation_use_case.dart", + "sha256": "67dea0a0619b82be5113881dca4259cbc0cf4fe41eedce5902e0a809934280e4", + "member": "lib/domain/use-cases/clear_timed_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/create_custom_preparation_use_case.dart", + "sha256": "9e65bad6207798a9b5388e49d9d41e47e58334a7ff6a2cbe629c8f7976c179e6", + "member": "lib/domain/use-cases/create_custom_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/create_preparation_template_use_case.dart", + "sha256": "b2d2eb7688917a8383bf0e07e54aef240e0f7c37c15314bfe7707bf88a4b3175", + "member": "lib/domain/use-cases/create_preparation_template_use_case.dart" + }, + { + "path": "lib/domain/use-cases/create_schedule_form_submission_use_case.dart", + "sha256": "839b370b1ee876612a761848d2fb038c6f83741d520de05c9ca9e4c0ddec72c8", + "member": "lib/domain/use-cases/create_schedule_form_submission_use_case.dart" + }, + { + "path": "lib/domain/use-cases/create_schedule_with_place_use_case.dart", + "sha256": "3352e6ac345b58f8d2d07372d32152555447f276fe7c95336d51c6ca4c876356", + "member": "lib/domain/use-cases/create_schedule_with_place_use_case.dart" + }, + { + "path": "lib/domain/use-cases/default_preferences_workflow.dart", + "sha256": "6f3d621b8f094d1c1964f299ce96f6bfa2e3f24f6bdad8969a37cbc7023fd2c7", + "member": "lib/domain/use-cases/default_preferences_workflow.dart" + }, + { + "path": "lib/domain/use-cases/delete_preparation_template_use_case.dart", + "sha256": "2853fc103bdd5d34003ed4ccec7fbdd61450aea1ec0663b49ab1b01880a8ec32", + "member": "lib/domain/use-cases/delete_preparation_template_use_case.dart" + }, + { + "path": "lib/domain/use-cases/delete_schedule_use_case.dart", + "sha256": "5599df8a6d14dbd6d34338be6ff6ccc2e3b894ca1f7a83aedc7571a2408420f9", + "member": "lib/domain/use-cases/delete_schedule_use_case.dart" + }, + { + "path": "lib/domain/use-cases/finish_schedule_use_case.dart", + "sha256": "8d7f20825f49cc10ad85e27d430db7b8db77aa90fbd29db278cbfc132a0a44c4", + "member": "lib/domain/use-cases/finish_schedule_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart", + "sha256": "0865a8d103c4bb0d6be7d0b66b9423dea5df4c79ab03597cabc2b0ea073811a5", + "member": "lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_default_preparation_use_case.dart", + "sha256": "fd899f4c11929db72c380ec5793517e6a00a5f2aa6c79219759b379a1e780619", + "member": "lib/domain/use-cases/get_default_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_early_start_session_use_case.dart", + "sha256": "2f59b8e5835f032c9477458a3f376fccad6ba0a3c94fa6a91fef890c063f2299", + "member": "lib/domain/use-cases/get_early_start_session_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart", + "sha256": "99aff749bb991738ffc2b223e78e97e23949a60aa9512a7b1086e3537c400ac4", + "member": "lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_preparation_by_schedule_id_use_case.dart", + "sha256": "ab8f51433b9efd6789ebef58eca4b9051d9e6faca0d0ea9bb181aeb7f5582de9", + "member": "lib/domain/use-cases/get_preparation_by_schedule_id_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_preparation_template_use_case.dart", + "sha256": "4c4a5dfbb25a6b57f073260f2fe2a48dd65d465111008eae2d1e73a750c57a77", + "member": "lib/domain/use-cases/get_preparation_template_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_preparation_templates_use_case.dart", + "sha256": "53115e7e9bd3eda925961d9a5afd74d8efa7df6d380d0639386e350274f4c220", + "member": "lib/domain/use-cases/get_preparation_templates_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_schedule_by_id_use_case.dart", + "sha256": "9d9937d3abf864b3739020614b5ffa0fd8dcaa1d6aca028fa2a2488a459b54ce", + "member": "lib/domain/use-cases/get_schedule_by_id_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_schedules_by_date_use_case.dart", + "sha256": "37993cdcee0db59c30903fce7a1b86c21cc2a97a5495045820dad6003b938620", + "member": "lib/domain/use-cases/get_schedules_by_date_use_case.dart" + }, + { + "path": "lib/domain/use-cases/get_timed_preparation_snapshot_use_case.dart", + "sha256": "f38dcdd3ad69a1847f0fdd043cc6b8790dac24835de133cef379d6bb64eea80e", + "member": "lib/domain/use-cases/get_timed_preparation_snapshot_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart", + "sha256": "3c8e688ae097ea69a44402339809d4739f0da503a7244e25d5df5a081dd662e3", + "member": "lib/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_preparation_by_schedule_id_use_case.dart", + "sha256": "9e4557715b3391cbe74b8c0ef6992f008ecc2d42279322c280703ad5438fe5c8", + "member": "lib/domain/use-cases/load_preparation_by_schedule_id_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_schedule_form_draft_use_case.dart", + "sha256": "33007380e96a4c6b8447db0ce9038b5afc0192003c231ed85c1e8e9f995f9e30", + "member": "lib/domain/use-cases/load_schedule_form_draft_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_schedules_by_date_use_case.dart", + "sha256": "1b30e58409b0e6e1b40515c3b5f5eb37a9202986c4f8823a39a24d0361302ccb", + "member": "lib/domain/use-cases/load_schedules_by_date_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_schedules_for_month_use_case.dart", + "sha256": "5d9fbc5bab20825c397c9738e24c74d60c34bf18318faf35e38800af298a7685", + "member": "lib/domain/use-cases/load_schedules_for_month_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_schedules_for_week_use_case.dart", + "sha256": "589374272e31c7e06cf46f3af807ac323b4f068afcf78c1b969d61fc5b9f9734", + "member": "lib/domain/use-cases/load_schedules_for_week_use_case.dart" + }, + { + "path": "lib/domain/use-cases/load_user_use_case.dart", + "sha256": "1be68c80a72346c4126fd07be03547b5acd5c79ac5aedc865ebc504220950d7d", + "member": "lib/domain/use-cases/load_user_use_case.dart" + }, + { + "path": "lib/domain/use-cases/local_data_workflows.dart", + "sha256": "635f802f7cf06fdd8ab7e33e332df7e265f6cb253bb3bac679b1552e0434815d", + "member": "lib/domain/use-cases/local_data_workflows.dart" + }, + { + "path": "lib/domain/use-cases/mark_early_start_session_use_case.dart", + "sha256": "ffdd344fff94b5f481352f258b42a21f43f3a56175348794297e2083379256e0", + "member": "lib/domain/use-cases/mark_early_start_session_use_case.dart" + }, + { + "path": "lib/domain/use-cases/onboard_use_case.dart", + "sha256": "b5425eb8369af2e7bae074b4f4c4aa472f8c3f2f6502893791019655df1c285a", + "member": "lib/domain/use-cases/onboard_use_case.dart" + }, + { + "path": "lib/domain/use-cases/reconcile_alarms_use_case.dart", + "sha256": "4c5fd591929220046458ba810722f4d5ad56bcb0cdd90fe60c3d7798f914558a", + "member": "lib/domain/use-cases/reconcile_alarms_use_case.dart" + }, + { + "path": "lib/domain/use-cases/recurring_schedules_use_case.dart", + "sha256": "8f0594074abee97deafc162420c17013ad6076843d2181ed32d0500b802d2c1d", + "member": "lib/domain/use-cases/recurring_schedules_use_case.dart" + }, + { + "path": "lib/domain/use-cases/recurring_time_correction_workflow.dart", + "sha256": "b11db31ea216f73f90e3ba145f21fdc13ab6a71e5aeffd518f71215515c1f4ba", + "member": "lib/domain/use-cases/recurring_time_correction_workflow.dart" + }, + { + "path": "lib/domain/use-cases/save_timed_preparation_use_case.dart", + "sha256": "d12db73deffddee2de258f30b664656432d05860ed9a239b0e1da07fa9d8fd8b", + "member": "lib/domain/use-cases/save_timed_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/schedule_form_submission.dart", + "sha256": "58e62c1085ca435ede7404725609eb9a713149f29570e63ccad28c591944dae7", + "member": "lib/domain/use-cases/schedule_form_submission.dart" + }, + { + "path": "lib/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart", + "sha256": "c57bd8d8fc3204e920f677925d2ce5f1f6953062d38bef41c2cb1c51f465b6b5", + "member": "lib/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart" + }, + { + "path": "lib/domain/use-cases/schedule_preparation_session_use_case.dart", + "sha256": "4b269b1df78e409d6cbb412ff6bbb2e69d281633c5bed23a331bc88cfc8ac52b", + "member": "lib/domain/use-cases/schedule_preparation_session_use_case.dart" + }, + { + "path": "lib/domain/use-cases/schedule_save_workflow.dart", + "sha256": "3eb8dcbbe095a31a972c95a56360b8275082b5a07a0e946d6d05c931ff971477", + "member": "lib/domain/use-cases/schedule_save_workflow.dart" + }, + { + "path": "lib/domain/use-cases/schedule_time_correction_workflow.dart", + "sha256": "74a8f6e27c3e3a28b8ceeb4704fe7f96431711c68266d0324a8420703e1a6679", + "member": "lib/domain/use-cases/schedule_time_correction_workflow.dart" + }, + { + "path": "lib/domain/use-cases/start_schedule_use_case.dart", + "sha256": "acf490fd3ae19994d8f6caca0293f5123ccd37c2d3e86ec10f0b895ceee8d1f2", + "member": "lib/domain/use-cases/start_schedule_use_case.dart" + }, + { + "path": "lib/domain/use-cases/stream_preparations_use_case.dart", + "sha256": "ea79e83c9949f1b86758f6bec6fca52446eb021a7d3eb2987c211bd7fc0beaec", + "member": "lib/domain/use-cases/stream_preparations_use_case.dart" + }, + { + "path": "lib/domain/use-cases/stream_user_use_case.dart", + "sha256": "1f2dcb075f1a33024e2720941ea19cc886b29932cb4f2664793a69c743c9b934", + "member": "lib/domain/use-cases/stream_user_use_case.dart" + }, + { + "path": "lib/domain/use-cases/update_default_preparation_use_case.dart", + "sha256": "f82e82a391bdb3bd956ecedba14e2b0d00e01848b1b5aac5d3828c50171fab41", + "member": "lib/domain/use-cases/update_default_preparation_use_case.dart" + }, + { + "path": "lib/domain/use-cases/update_preparation_by_schedule_id_use_case.dart", + "sha256": "364f367b7b167b03d39b181724a3116e48e369910750e3f2aa7c54ce1e9e4496", + "member": "lib/domain/use-cases/update_preparation_by_schedule_id_use_case.dart" + }, + { + "path": "lib/domain/use-cases/update_preparation_template_use_case.dart", + "sha256": "022bb5f4cbd1798f30157545d32d695d79c444add58595fa1ab5bdd37fb68d17", + "member": "lib/domain/use-cases/update_preparation_template_use_case.dart" + }, + { + "path": "lib/domain/use-cases/update_schedule_form_submission_use_case.dart", + "sha256": "975215ea2d3e869b3da5c9e5c3acb643a79f869f18f81fa37289ac4ae5a8a4fa", + "member": "lib/domain/use-cases/update_schedule_form_submission_use_case.dart" + }, + { + "path": "lib/domain/use-cases/update_schedule_use_case.dart", + "sha256": "de3541d03299b4da5efd816161ca8b06442ed23ae416f95fbe6ccb0cd7b0a8a7", + "member": "lib/domain/use-cases/update_schedule_use_case.dart" + }, + { + "path": "lib/domain/use-cases/update_spare_time_use_case.dart", + "sha256": "85ee4388108d79f5973b335e39926465ef68cbee939cd54f292d2f27af269878", + "member": "lib/domain/use-cases/update_spare_time_use_case.dart" + }, + { + "path": "lib/l10n/app_en.arb", + "sha256": "dbf4e9fc1abded2f0173c497320cbb35a2cf2bbd8d7bf27d5f22277ec5171db8", + "member": "lib/l10n/app_en.arb" + }, + { + "path": "lib/l10n/app_ko.arb", + "sha256": "535aa5818ec3bf7a5b89127b54ef821f693cdebd0a7658c7aa11f3d9a1b2ce2c", + "member": "lib/l10n/app_ko.arb" + }, + { + "path": "lib/l10n/app_localizations.dart", + "sha256": "75106d99e1b88572d565e83ff70d79276ef06f591d0b22a6fbee774789860ed8", + "member": "lib/l10n/app_localizations.dart" + }, + { + "path": "lib/l10n/app_localizations_en.dart", + "sha256": "cd795c49437ad1ffe1a55e36424dd16b8a3348beedf7ff7b318523b90a308e1d", + "member": "lib/l10n/app_localizations_en.dart" + }, + { + "path": "lib/l10n/app_localizations_ko.dart", + "sha256": "890932e3b0c224696e33bd95ae75992cfe72d597c2db67a4f9def1086bcb9b5e", + "member": "lib/l10n/app_localizations_ko.dart" + }, + { + "path": "lib/main.dart", + "sha256": "d37acfe98b8348a958f0fc7c55201edfe21296f1306164e35c4b7258d0d885b4", + "member": "lib/main.dart" + }, + { + "path": "lib/presentation/alarm/components/alarm_graph_animator.dart", + "sha256": "ed37baa30ed39898e1424c832875557010b6f1dc4051f9d82b34ff8777aa7e9f", + "member": "lib/presentation/alarm/components/alarm_graph_animator.dart" + }, + { + "path": "lib/presentation/alarm/components/alarm_graph_component.dart", + "sha256": "5bdc32f010e7da7aa312654eefed20cc871499bf3c5241c9d582f86086b636b5", + "member": "lib/presentation/alarm/components/alarm_graph_component.dart" + }, + { + "path": "lib/presentation/alarm/components/alarm_screen_bottom_section.dart", + "sha256": "4353f1ff97338f6b6577aae5dbc2b23bb411f0dd673a493c38395c4e05831ee6", + "member": "lib/presentation/alarm/components/alarm_screen_bottom_section.dart" + }, + { + "path": "lib/presentation/alarm/components/alarm_screen_top_section.dart", + "sha256": "a4a3565323291193f8057cf417fd3916c71d46551b0f247211ddc7f745a061de", + "member": "lib/presentation/alarm/components/alarm_screen_top_section.dart" + }, + { + "path": "lib/presentation/alarm/components/preparation_completion_dialog.dart", + "sha256": "557159640456ba18b1b7b6ba6df8dd983c8cbd8e354bf4c1d55f23f7db5c4a1d", + "member": "lib/presentation/alarm/components/preparation_completion_dialog.dart" + }, + { + "path": "lib/presentation/alarm/components/preparation_step_list_widget.dart", + "sha256": "06e661e82b20f5f57d497140547c1fc6d0a4cedfe56c0622bfc9519bcdefe363", + "member": "lib/presentation/alarm/components/preparation_step_list_widget.dart" + }, + { + "path": "lib/presentation/alarm/components/preparation_step_tile.dart", + "sha256": "96a68057dc3b425d5be0f822ae0343113a06d51ac438181349dbc05da3305bcb", + "member": "lib/presentation/alarm/components/preparation_step_tile.dart" + }, + { + "path": "lib/presentation/alarm/screens/alarm_screen.dart", + "sha256": "c19dc1ca73f4377382fa4b236ab6a7c3958392b49a6e1f01079aa5ae3b2ebbf8", + "member": "lib/presentation/alarm/screens/alarm_screen.dart" + }, + { + "path": "lib/presentation/alarm/screens/notification_schedule_start_route.dart", + "sha256": "ff9ab41056b411b6c706dd123755db25892e175f12480a4d0a89a8f0d2f5f4a0", + "member": "lib/presentation/alarm/screens/notification_schedule_start_route.dart" + }, + { + "path": "lib/presentation/alarm/screens/schedule_start_screen.dart", + "sha256": "903e90a4ca9e7cc541d4ed07309062f548ac44104bced8ccc95576dcb9a5b2ba", + "member": "lib/presentation/alarm/screens/schedule_start_screen.dart" + }, + { + "path": "lib/presentation/alarm/utils/preparation_step_state_mapper.dart", + "sha256": "2cea460d21133d1958bcde6c215a87c09c1899fdedc17e053d876218cbe88c7d", + "member": "lib/presentation/alarm/utils/preparation_step_state_mapper.dart" + }, + { + "path": "lib/presentation/alarm_allow/screens/alarm_allow_screen.dart", + "sha256": "8cef5ba104046b1bcf1d6071387ad27c8f8bebf080e6f44d20a9071723e0fd1a", + "member": "lib/presentation/alarm_allow/screens/alarm_allow_screen.dart" + }, + { + "path": "lib/presentation/app/bloc/auth/auth_bloc.dart", + "sha256": "b8b6787ca4e4cc4ad4b0dd0589ccaab80625361813a825c82d1826aac79a3eed", + "member": "lib/presentation/app/bloc/auth/auth_bloc.dart" + }, + { + "path": "lib/presentation/app/bloc/auth/auth_event.dart", + "sha256": "c6c69f06aff8238d9678011b6244ef865b5128c0191303ae3a578e7f4f4da652", + "member": "lib/presentation/app/bloc/auth/auth_event.dart" + }, + { + "path": "lib/presentation/app/bloc/auth/auth_state.dart", + "sha256": "66910b6b7e005f18149adbbd6e27353065243bbec8670ac13f22e4e1ecfca712", + "member": "lib/presentation/app/bloc/auth/auth_state.dart" + }, + { + "path": "lib/presentation/app/bloc/schedule/schedule_bloc.dart", + "sha256": "e91a62f731f0605df8810d10cd2fc0b65eb05e53ccfb1e51876f236ef849d6bd", + "member": "lib/presentation/app/bloc/schedule/schedule_bloc.dart" + }, + { + "path": "lib/presentation/app/bloc/schedule/schedule_event.dart", + "sha256": "ab54737d3e9089bb381638697b4837b13596a0daedf9e83128c3de895faecbc7", + "member": "lib/presentation/app/bloc/schedule/schedule_event.dart" + }, + { + "path": "lib/presentation/app/bloc/schedule/schedule_state.dart", + "sha256": "0d14268ee1cf7287952d06b0eaa6d5edb0851b8aabc92d251f5f661419995d1c", + "member": "lib/presentation/app/bloc/schedule/schedule_state.dart" + }, + { + "path": "lib/presentation/app/cubit/alarm_gate_cubit.dart", + "sha256": "2e97460db5f79359ae97dd6fd85e1fa2dfccc2e493e72c66a3766898444cdbbd", + "member": "lib/presentation/app/cubit/alarm_gate_cubit.dart" + }, + { + "path": "lib/presentation/app/cubit/alarm_gate_state.dart", + "sha256": "e130cf727cd7b443992d6c3752ec023f1fb069ae1a305ed164ea201d4eeea742", + "member": "lib/presentation/app/cubit/alarm_gate_state.dart" + }, + { + "path": "lib/presentation/app/cubit/notification_gate_cubit.dart", + "sha256": "e4800ce38e9f941db23b4e9b2003fdf17f9cd5650d6d9099b52abc40cbcd9f47", + "member": "lib/presentation/app/cubit/notification_gate_cubit.dart" + }, + { + "path": "lib/presentation/app/cubit/notification_gate_state.dart", + "sha256": "240dc7c42dac98b8ff25ba7a00d4bcda49a585c18d4b27d2fe5ed488fc31a716", + "member": "lib/presentation/app/cubit/notification_gate_state.dart" + }, + { + "path": "lib/presentation/app/screens/app.dart", + "sha256": "00f817318513b907d14b6ea3e57aab0546af1acae7c405def434c4c22d1f0c15", + "member": "lib/presentation/app/screens/app.dart" + }, + { + "path": "lib/presentation/backup/backup_processing_panel.dart", + "sha256": "f6c2fc5c95e36af40cf8536b0f9864e9354b71d451d3121bb3ec13214aaae463", + "member": "lib/presentation/backup/backup_processing_panel.dart" + }, + { + "path": "lib/presentation/backup/backup_recurring_time_review_screen.dart", + "sha256": "3d995509794a30f3ba520f078a5e27c00c008ba9d1e0168cef0806a1420be2bd", + "member": "lib/presentation/backup/backup_recurring_time_review_screen.dart" + }, + { + "path": "lib/presentation/backup/backup_time_review_screen.dart", + "sha256": "8dcb1dce7a3587502d90774135127b333d651abe4d5d1c0729463550ffd7afca", + "member": "lib/presentation/backup/backup_time_review_screen.dart" + }, + { + "path": "lib/presentation/calendar/bloc/monthly_schedules_bloc.dart", + "sha256": "53a121c93c9eb6a910dd62475744fa9be2f0e6d381a170ec4f6cb3702c4dce3a", + "member": "lib/presentation/calendar/bloc/monthly_schedules_bloc.dart" + }, + { + "path": "lib/presentation/calendar/bloc/monthly_schedules_event.dart", + "sha256": "50f24adcbc94ba49081783c8097aa5ece8790ac3bbbd57089c1be1b9d2dcd437", + "member": "lib/presentation/calendar/bloc/monthly_schedules_event.dart" + }, + { + "path": "lib/presentation/calendar/bloc/monthly_schedules_state.dart", + "sha256": "c411a2b7d5d1ec82f540be0876dcb44df59748e81cfbd74562d1a8cfad49dd62", + "member": "lib/presentation/calendar/bloc/monthly_schedules_state.dart" + }, + { + "path": "lib/presentation/calendar/component/schedule_deletion_dialog.dart", + "sha256": "d687bf3cbb06c46bb996dbfafee2f9a6ce8ae0482f5441a84df4544f848a7680", + "member": "lib/presentation/calendar/component/schedule_deletion_dialog.dart" + }, + { + "path": "lib/presentation/calendar/component/schedule_detail.dart", + "sha256": "ad98094421f21c80a223f61c55dca5ebed53e7d91ae4db0e66f97c36270649eb", + "member": "lib/presentation/calendar/component/schedule_detail.dart" + }, + { + "path": "lib/presentation/calendar/screens/calendar_screen.dart", + "sha256": "b55f9f20a57f8b33c2f1a3dd4ee69bac9cb6d645afb7122cd6ffbf4c033fd726", + "member": "lib/presentation/calendar/screens/calendar_screen.dart" + }, + { + "path": "lib/presentation/early_late/bloc/early_late_screen_bloc.dart", + "sha256": "f996d7aeeb3268a290c76c18046ff4b83233e0711e9387349c9e5a661707e337", + "member": "lib/presentation/early_late/bloc/early_late_screen_bloc.dart" + }, + { + "path": "lib/presentation/early_late/bloc/early_late_screen_event.dart", + "sha256": "a9f7b39a26ae682e5f6cc595facd88c11fc263492a1e6befeb048548312f86c0", + "member": "lib/presentation/early_late/bloc/early_late_screen_event.dart" + }, + { + "path": "lib/presentation/early_late/bloc/early_late_screen_state.dart", + "sha256": "7d96e1592e45e9cb3fa53f64968702dcd0dcec5a4406f9df9b7cbdacde0b6a2c", + "member": "lib/presentation/early_late/bloc/early_late_screen_state.dart" + }, + { + "path": "lib/presentation/early_late/components/check_list_box_widget.dart", + "sha256": "17c48dd49b64463daa0e0a9236944e06719411cf4b0f5a39b0e4c36fa586c2b3", + "member": "lib/presentation/early_late/components/check_list_box_widget.dart" + }, + { + "path": "lib/presentation/early_late/components/check_list_item_widget.dart", + "sha256": "46725440280e9536843a90b3438e016737fbbad9f295d758b0c0aa926f38a3c5", + "member": "lib/presentation/early_late/components/check_list_item_widget.dart" + }, + { + "path": "lib/presentation/early_late/components/early_late_message_image_widget.dart", + "sha256": "9909c37278b758bd8a5178e5ff50fbc2afb905ad1a7d213137c063ec5160e26e", + "member": "lib/presentation/early_late/components/early_late_message_image_widget.dart" + }, + { + "path": "lib/presentation/early_late/screens/early_late_screen.dart", + "sha256": "67d348472a5b1e1ff17bd677736e6d8f27c1029d6f1b63aedc2418cda69431aa", + "member": "lib/presentation/early_late/screens/early_late_screen.dart" + }, + { + "path": "lib/presentation/home/bloc/schedule_timer_bloc.dart", + "sha256": "1e143ce9312246ef58f447bcd6687d6408c8146d7513bbc2c42522df19b55b03", + "member": "lib/presentation/home/bloc/schedule_timer_bloc.dart" + }, + { + "path": "lib/presentation/home/bloc/schedule_timer_event.dart", + "sha256": "2012a71a141e36eb0e21ff8f540de463f0707e1fa4bf48de6923e068b85d1979", + "member": "lib/presentation/home/bloc/schedule_timer_event.dart" + }, + { + "path": "lib/presentation/home/bloc/schedule_timer_state.dart", + "sha256": "8b07c64c5ec929bd977fef9637b6017c8a57b4d3ba0414793b7826bc3061e9b9", + "member": "lib/presentation/home/bloc/schedule_timer_state.dart" + }, + { + "path": "lib/presentation/home/bloc/weekly_schedules_bloc.dart", + "sha256": "1efdf83531614aa4197f54fa4319614ced03191b86ecaeed43becadb7c22bcd3", + "member": "lib/presentation/home/bloc/weekly_schedules_bloc.dart" + }, + { + "path": "lib/presentation/home/bloc/weekly_schedules_event.dart", + "sha256": "a5f5805f1e88fa7f2121b75dad4beca4dceb992b1e4c117e66389e72bd6d17a3", + "member": "lib/presentation/home/bloc/weekly_schedules_event.dart" + }, + { + "path": "lib/presentation/home/bloc/weekly_schedules_state.dart", + "sha256": "dbc7f607f599a44efc2e5f14550f77933ef896afbc1d72eb1cf48ac15c219f03", + "member": "lib/presentation/home/bloc/weekly_schedules_state.dart" + }, + { + "path": "lib/presentation/home/components/device_day_refresh.dart", + "sha256": "754fd660950109bf42ac21b5d5307129f4f1e85471aaf54703c08c8216545686", + "member": "lib/presentation/home/components/device_day_refresh.dart" + }, + { + "path": "lib/presentation/home/components/home_app_bar.dart", + "sha256": "1f208919fa9a0ad892e9f2cce92123d09435635b2a1f44f9db1262677da4e07c", + "member": "lib/presentation/home/components/home_app_bar.dart" + }, + { + "path": "lib/presentation/home/components/month_calendar.dart", + "sha256": "7212916513b58572cf9e88621de640bbce87d726e3e218a1d56e047ff4c338c0", + "member": "lib/presentation/home/components/month_calendar.dart" + }, + { + "path": "lib/presentation/home/components/nearest_schedule_query_status.dart", + "sha256": "c1b67a1d0419bfea5f984480ae6661e1a0909e42d2f0a6c3375224fbe6ed6ae5", + "member": "lib/presentation/home/components/nearest_schedule_query_status.dart" + }, + { + "path": "lib/presentation/home/components/todays_schedule_tile.dart", + "sha256": "6679149b96dc695022bde9d735bff1595284e21599b17cc27cfd5f6a696bff7c", + "member": "lib/presentation/home/components/todays_schedule_tile.dart" + }, + { + "path": "lib/presentation/home/components/week_calendar.dart", + "sha256": "15cf725794b45a5d229029eb160ecb3f9cd9bd014a850aef34d0f0abaaab55fd", + "member": "lib/presentation/home/components/week_calendar.dart" + }, + { + "path": "lib/presentation/home/screens/home_screen.dart", + "sha256": "d105d27fea2395aae1392b68058ed82a141bba07823bb392cdb405cc97ea694f", + "member": "lib/presentation/home/screens/home_screen.dart" + }, + { + "path": "lib/presentation/home/screens/home_screen_tmp.dart", + "sha256": "b785409f15d320bd42a3f5f37efc7c0150de74c05109a76d56c46da1dca628ec", + "member": "lib/presentation/home/screens/home_screen_tmp.dart" + }, + { + "path": "lib/presentation/home/utils/home_schedule_card_selection.dart", + "sha256": "0ab69cdf26852f5bb5595d7801443ad805505f72c43557db68888da67d515a72", + "member": "lib/presentation/home/utils/home_schedule_card_selection.dart" + }, + { + "path": "lib/presentation/home/utils/today_tile_navigation.dart", + "sha256": "6f459e298de9724876c6c38e4a2335e37312db13d548cddd26bf14cf44ab41b4", + "member": "lib/presentation/home/utils/today_tile_navigation.dart" + }, + { + "path": "lib/presentation/moving/screens/moving_screen.dart", + "sha256": "afbe4ffced63b31335a76fc1e7698c63989e5cd803050f6ffe29f76fc8ef7b52", + "member": "lib/presentation/moving/screens/moving_screen.dart" + }, + { + "path": "lib/presentation/my_page/cubit/detailed_notification_settings_cubit.dart", + "sha256": "b2b21ed23144ea411745946e72040b4dbc6254f798c692ea60e1b33749af93e0", + "member": "lib/presentation/my_page/cubit/detailed_notification_settings_cubit.dart" + }, + { + "path": "lib/presentation/my_page/cubit/detailed_notification_settings_state.dart", + "sha256": "48c8d7c21f4c8a3936c2c49e8197655c636b14cc992c4735a16a8149f420734c", + "member": "lib/presentation/my_page/cubit/detailed_notification_settings_state.dart" + }, + { + "path": "lib/presentation/my_page/my_data_screen.dart", + "sha256": "b862028c8265503079830dcbf933aa421051db40aaf2fb59ae7a8fd31685f4c0", + "member": "lib/presentation/my_page/my_data_screen.dart" + }, + { + "path": "lib/presentation/my_page/my_page_screen.dart", + "sha256": "8851a5f76a0ea4887e3cfaf7098dcb30037cfaea8d141d16f8a10116c94e6078", + "member": "lib/presentation/my_page/my_page_screen.dart" + }, + { + "path": "lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart", + "sha256": "ebd45ed8fd1aeff8c1f40460bad871c2dfa1db94f168b6ac1797a13df31fe6de", + "member": "lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart" + }, + { + "path": "lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_event.dart", + "sha256": "650c44789ded6fc978ed646e22eacc476077d752f18fbd14d0ea7b60cec08320", + "member": "lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_event.dart" + }, + { + "path": "lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart", + "sha256": "29cabf5e9f9cd1fe2429d0d8b29fa8e6aa0b68f14296a365a46aabaa8b7b4028", + "member": "lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart" + }, + { + "path": "lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart", + "sha256": "bd864abb26f6a068e78f9e7ac4f5354f7014bb755a7b994ab484362153753d4f", + "member": "lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart" + }, + { + "path": "lib/presentation/my_page/privacy_policy_screen.dart", + "sha256": "a04b6ffc392c14023c1ae89bd00a7bece0c801136f00eff6b8babd7f07cd88e9", + "member": "lib/presentation/my_page/privacy_policy_screen.dart" + }, + { + "path": "lib/presentation/notification_allow/screens/notification_allow_screen.dart", + "sha256": "98be51d9cdb0f5129aa461522d830b69db99d1aa1cac531fcb6b60bfd90caa8b", + "member": "lib/presentation/notification_allow/screens/notification_allow_screen.dart" + }, + { + "path": "lib/presentation/onboarding/components/onboarding_page_view_layout.dart", + "sha256": "70cd236da27d4067462ccf8789afbccf5102b890bfb7e0c89744310b625cf44b", + "member": "lib/presentation/onboarding/components/onboarding_page_view_layout.dart" + }, + { + "path": "lib/presentation/onboarding/components/onboarding_title.dart", + "sha256": "9ff1b70ecb872b5f98cfeccf8179f47ab3a47a323654bdcb080db6cc49bb22d6", + "member": "lib/presentation/onboarding/components/onboarding_title.dart" + }, + { + "path": "lib/presentation/onboarding/cubit/onboarding_cubit.dart", + "sha256": "9f3bdae18dd3c0509a99a9e772dd46e79cbc0d4eb434f26d0ed85de3d41a8623", + "member": "lib/presentation/onboarding/cubit/onboarding_cubit.dart" + }, + { + "path": "lib/presentation/onboarding/cubit/onboarding_state.dart", + "sha256": "1d8e9044020d1f7eb8c39f19ad8b84b725d35664f90fff633f9de67d75aa55cf", + "member": "lib/presentation/onboarding/cubit/onboarding_state.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/components/create_icon_button.dart", + "sha256": "49ff8c85d7c9b0c739250b87ff77485e640ec1ce7b3222969089061d439cfb1c", + "member": "lib/presentation/onboarding/preparation_name_select/components/create_icon_button.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/components/preparation_create_list.dart", + "sha256": "017513d12028e1cef25ea2f2bf93ca91914325fe8b65a8c01e7118ef24491ea8", + "member": "lib/presentation/onboarding/preparation_name_select/components/preparation_create_list.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart", + "sha256": "693a9f66daaf3ecc851a8288c16b1a61d530581ebf5a28ce3ed174f1c3501431", + "member": "lib/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/components/preparation_select_list.dart", + "sha256": "2c0454ddd0c5fa78ea57f19f836061f8c2add2ed0e166f834c3306220231c765", + "member": "lib/presentation/onboarding/preparation_name_select/components/preparation_select_list.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart", + "sha256": "e46d2063d89d7ed77f370f871e62d504ed9da0386e5b52be0860b843aa452334", + "member": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_state.dart", + "sha256": "9afd6432f38beabb80498b0a09550f04cbada24b34696f70229d209e8b26c024", + "member": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_state.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart", + "sha256": "f8593376df00bc228a3307c223b9e8e43e11c2384233e3124c98aa906b17c466", + "member": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_state.dart", + "sha256": "ff1dbfe8f78f12ae5c747dadd4295f4b1550d461838f051477a1dfc256c1c6dc", + "member": "lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_state.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart", + "sha256": "5014ec0d0112b7672e96d81df431fa0bc125f2dd087d1c347917e55053c9350d", + "member": "lib/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart", + "sha256": "a3f5976ad86345c05835f8aaf8c669c7dea02fcc241fc3bc84e494da94137b37", + "member": "lib/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart", + "sha256": "8c8793d70791e923403062f1079cef08234bd2364a1548c2ffaefff85392dd69", + "member": "lib/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_order/components/reorderable_tile.dart", + "sha256": "218bebcffa99eb3e69a0270fb1dcf9dc86c5a734c89fc106e2e2289a4dc5a74f", + "member": "lib/presentation/onboarding/preparation_order/components/reorderable_tile.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart", + "sha256": "3208a4cf3fbb86473768d0f2299005b7001e83123533f06d7fa8abe65eb8ea9b", + "member": "lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_order/cubit/preparation_order_state.dart", + "sha256": "6f5009747fad8332010fed6f950ab7f965b9ac9a01cb50eda2a9ffcd9d0b0c56", + "member": "lib/presentation/onboarding/preparation_order/cubit/preparation_order_state.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart", + "sha256": "8d4717267f08c54809ba8ae3dbc49448a1726e77e37fd0a4c0a1807ff23a3332", + "member": "lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_time/components/preparation_time_input_list.dart", + "sha256": "aaaed48d4f8b5b3929ef988ec8b5c691c7007ca0d867dd2bd23ad9c68f2bf573", + "member": "lib/presentation/onboarding/preparation_time/components/preparation_time_input_list.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart", + "sha256": "b1c2713ffbfb51cde46bbdc4369c30a81217394a2538ffd195a5b7fee0a97c50", + "member": "lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart", + "sha256": "974fc861181ce220bd0e41596ac73c99cc720580f9fe9da8fa8560160c8c71ec", + "member": "lib/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_time/cubit/preparation_time_state.dart", + "sha256": "7cb4435944e644447a29e23fa23d27a79b98629a17512322c7a4453563ae679a", + "member": "lib/presentation/onboarding/preparation_time/cubit/preparation_time_state.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart", + "sha256": "eadd4ff20ef7dae4538c434b3144c8dc21703f15623259ed5d018fdcb4daae80", + "member": "lib/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart" + }, + { + "path": "lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart", + "sha256": "aee3dac620c9ecca0e57f4fdfb759a846a55ed2d8edc1fcd3a408685fb369a08", + "member": "lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart" + }, + { + "path": "lib/presentation/onboarding/schedule_spare_time/components/shcedule_spare_time_field.dart", + "sha256": "8a4ac813a36584390d9a42cb6e81e9760a59889198c42a76b440a1b36f743d64", + "member": "lib/presentation/onboarding/schedule_spare_time/components/shcedule_spare_time_field.dart" + }, + { + "path": "lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart", + "sha256": "ed89ccea55c48e7e5af60d831afbe5e84baad43ad579ca19cb443548fb731564", + "member": "lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart" + }, + { + "path": "lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart", + "sha256": "3a76b4a8cf4d70c7fe6a9f3971579b8f76badc85a68e3716fff0be1ed4ce8d48", + "member": "lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart" + }, + { + "path": "lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart", + "sha256": "16d812f5d590590b69ce0bc72ea0afd1a20f0c0e87b1483c28739b7b829fdeb0", + "member": "lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart" + }, + { + "path": "lib/presentation/onboarding/screens/onboarding_screen.dart", + "sha256": "ea2df70d24d5cc32cc5567a93471e8a5a10e3605afddc37f70bfaaed34293759", + "member": "lib/presentation/onboarding/screens/onboarding_screen.dart" + }, + { + "path": "lib/presentation/onboarding/screens/onboarding_start_screen.dart", + "sha256": "1509ce972f5de6b33449d6defeecfad036d9b62bd4c6e076bc01e6358b5c8439", + "member": "lib/presentation/onboarding/screens/onboarding_start_screen.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_components.dart", + "sha256": "f5dee883c5ae7776d8d3f1cbb283ff107125e3a266ea821a6c8e457a845d624f", + "member": "lib/presentation/recurring/recurrence_components.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_labels.dart", + "sha256": "7169303c215d0eb6bf80f644162a8f94161b07976d9dbce7a67933608e0549ba", + "member": "lib/presentation/recurring/recurrence_labels.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_occurrence_sheet.dart", + "sha256": "f110efb2159a6c7dbcdbba5616f4f1d90f5028d9da10b3edea9e08a7e8770023", + "member": "lib/presentation/recurring/recurrence_occurrence_sheet.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_review_sheet.dart", + "sha256": "f9f25cdd00ef9d960d7c56d6c70bdbd9efd4b550e2d2810bffeea5ca859ba661", + "member": "lib/presentation/recurring/recurrence_review_sheet.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_scope_sheet.dart", + "sha256": "8c1564da849737d293db50a4a189e3766aab9ab3ab237c7a413fed1dae34850e", + "member": "lib/presentation/recurring/recurrence_scope_sheet.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "sha256": "beb93f146ffc3cea336c18c7b9f3e5755c912ebb8862355b1543e8ded9f472c4", + "member": "lib/presentation/recurring/recurrence_settings_sheet.dart" + }, + { + "path": "lib/presentation/recurring/recurrence_time_choice_sheet.dart", + "sha256": "66d08acadc067691eae426ce4cb325ed59fd73899f0ab5b54f4bb3c331450bbd", + "member": "lib/presentation/recurring/recurrence_time_choice_sheet.dart" + }, + { + "path": "lib/presentation/recurring/recurring_management_screen.dart", + "sha256": "2d88128d3ebcc225faaf84ac2b3c4d5a805da9a1f13ad6f793e36e26a7e0ddd2", + "member": "lib/presentation/recurring/recurring_management_screen.dart" + }, + { + "path": "lib/presentation/recurring/recurring_time_correction_plan_summary.dart", + "sha256": "30941b880e441a85162d858b16b839913983203c3c72b331e476acf83fdeec77", + "member": "lib/presentation/recurring/recurring_time_correction_plan_summary.dart" + }, + { + "path": "lib/presentation/recurring/recurring_time_correction_screen.dart", + "sha256": "4e37354fca69f433832c5e0b3d12af029f2ecaa853513c363ee8771ef41cc5b2", + "member": "lib/presentation/recurring/recurring_time_correction_screen.dart" + }, + { + "path": "lib/presentation/schedule_create/bloc/schedule_form_bloc.dart", + "sha256": "16a6b4c1a4f81e9473b061abdd1e3d1d3e5ed428115da543aaa83d0bb7b48a0b", + "member": "lib/presentation/schedule_create/bloc/schedule_form_bloc.dart" + }, + { + "path": "lib/presentation/schedule_create/bloc/schedule_form_event.dart", + "sha256": "3f6acbb3c934abaad0f11c6f928704339f17d0759b38ebf192a22dd5d56f0d28", + "member": "lib/presentation/schedule_create/bloc/schedule_form_event.dart" + }, + { + "path": "lib/presentation/schedule_create/bloc/schedule_form_state.dart", + "sha256": "4fece26b8d771323466758ce830907bc902111f02cb9b242fd5a1d40dd512134", + "member": "lib/presentation/schedule_create/bloc/schedule_form_state.dart" + }, + { + "path": "lib/presentation/schedule_create/bloc/schedule_time_save_review.dart", + "sha256": "cb95f7e4a5f747ece4ef2e32f2bdd323420b96db6c895fc3bd8e52c84da60633", + "member": "lib/presentation/schedule_create/bloc/schedule_time_save_review.dart" + }, + { + "path": "lib/presentation/schedule_create/components/keyboard_backed_bottom_sheet.dart", + "sha256": "c5284ec29224d69c611b912d74d440c447769e1e1b73c9fabb2ab45ba7f607e9", + "member": "lib/presentation/schedule_create/components/keyboard_backed_bottom_sheet.dart" + }, + { + "path": "lib/presentation/schedule_create/components/message_bubble.dart", + "sha256": "29b6f42b0ea61d7200029a0c619918e276df1269ecc51e1f3a6efa3434fef254", + "member": "lib/presentation/schedule_create/components/message_bubble.dart" + }, + { + "path": "lib/presentation/schedule_create/components/preparation_reorderable_list_form_field.dart", + "sha256": "ef91f5f5aa5c6948794d9893381abd10cab61a6a55ece0ba4e11cfb4f342adb8", + "member": "lib/presentation/schedule_create/components/preparation_reorderable_list_form_field.dart" + }, + { + "path": "lib/presentation/schedule_create/components/schedule_multi_page_form.dart", + "sha256": "bfd9df5c5bca6d10f86b40158d5229ef603899c3877b6f626f81675a7925b790", + "member": "lib/presentation/schedule_create/components/schedule_multi_page_form.dart" + }, + { + "path": "lib/presentation/schedule_create/components/top_bar.dart", + "sha256": "093612173fcccc0a8370bf6353ccf34c2cb65f8f1b43d08603036e99e6702487", + "member": "lib/presentation/schedule_create/components/top_bar.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart", + "sha256": "aa689272529ae771628bd8a5a042be1c56cfba2fdc5b2dab9702d28a1d7998c9", + "member": "lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart", + "sha256": "e9e3e795ef710f8abab1f7b6b8429e3d790ed41b6635b0436fd024fdc4083832", + "member": "lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_date_time/input_models/schedule_date_input_model.dart", + "sha256": "73532c71fe4abdb4950b12254c6f734d5c3bc5866c6422c5856d716174678e9d", + "member": "lib/presentation/schedule_create/schedule_date_time/input_models/schedule_date_input_model.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_date_time/input_models/schedule_time_input_model.dart", + "sha256": "c4bd8c1107db2166659a3fcc6c8596e4bfbb7aada26f383558e5ad39000f80dd", + "member": "lib/presentation/schedule_create/schedule_date_time/input_models/schedule_time_input_model.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart", + "sha256": "2e53216c3d2063e9ae7519861b75feb3852e3ef070526a9df4009fda7d858d24", + "member": "lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart", + "sha256": "17d66b2c499abf9e85d67ed4faeca49ace558f95dd8aad2e29551d3649dc9b9c", + "member": "lib/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_name/cubit/schedule_name_cubit.dart", + "sha256": "627a33307a7e2d5f100946cc096ad317057e3faccc2c243843c916824abae760", + "member": "lib/presentation/schedule_create/schedule_name/cubit/schedule_name_cubit.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_name/cubit/schedule_name_state.dart", + "sha256": "7306754d3e5fb1e226c0491a7b9d5e4d47e668e26600d610c02fcc55b3433f4a", + "member": "lib/presentation/schedule_create/schedule_name/cubit/schedule_name_state.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_name/input_models/schedule_name_input_model.dart", + "sha256": "af715181e54123cdf8d962e81a0dbc19e203ed6a2eae19a987bf9d657a45fa1d", + "member": "lib/presentation/schedule_create/schedule_name/input_models/schedule_name_input_model.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_name/screens/schedule_name_form.dart", + "sha256": "098aa18942746143a97fd3ba1e1a75c85c12bfc118c0c4411d5b23ee975a82e8", + "member": "lib/presentation/schedule_create/schedule_name/screens/schedule_name_form.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_cubit.dart", + "sha256": "ff1fe81c0dc5fdaabf9ceee8bf304bf29793dd91538917332694c964e3d2fdfd", + "member": "lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_cubit.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_state.dart", + "sha256": "1c7d6f439a9d39bea96de9bc2ea7488c589d92eecd7c49a19198aad88983df88", + "member": "lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_state.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_moving_time_input_model.dart", + "sha256": "888828ae77625ffad9740e8b29d40167fa8fbb2c1a573f4ff16eb2e0ed0dc93f", + "member": "lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_moving_time_input_model.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_place_input_model.dart", + "sha256": "8c84fc1bd40deb193dd0e98975b25abf3b94edaf1576e44aeee319f0a3464049", + "member": "lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_place_input_model.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart", + "sha256": "370a6cb278cd5afa551e3517718272a862d19f599143fdb1c0be8880c7b52eae", + "member": "lib/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart", + "sha256": "4fd8e64f397ef91ae4ecc0a24365d71ced4c3f136d171dc88665665be2af7ba6", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state.dart", + "sha256": "d1e221e3814ab31964c149d87adbbfd2975dcd29c7dca991b595c793b59d2d18", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart", + "sha256": "a7a84cf2b66d963a0b99af4f2539923227b3417a3a59389bbb9faf85478abac7", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart", + "sha256": "7a4bfb1dd8f5b319a3c0594b88ea59a4e41d8169741175099798b98e108b5f63", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_event.dart", + "sha256": "a8ce756a34b5b6ce4c55542678995debd3e0401bec8ba90b554666163812d805", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_event.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_state.dart", + "sha256": "b1f1cd3d17f2f49902882e91b13dead14a5239ca6c5f5adeffb6398aed256b05", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_state.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart", + "sha256": "108c4fbf61c9ce11b053a41107b473a359518182bd05d3a5181e9cdae1052d5b", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart", + "sha256": "c02a2814133c42075a8e435935b38bfe6a6a6219771d79d705efc9ebb0da21ab", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart", + "sha256": "91480ec26c26da89e6765257c6323e20b252dcf05d6968e1fecf4825aca8024a", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart", + "sha256": "0562e6cb4923a575f155f6668c7098b953e1563b7502d30bf3289fb5637b4025", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart", + "sha256": "aaf683923ff0f89a33b4f0b218bef78f2ff450c2b6bcc725fb0f0d41815026d6", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart", + "sha256": "33e2f996666004d99a04fa7ac793f0024388f7ac99442f74c2b4c69b1e9f16fe", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_state.dart", + "sha256": "bd7ab5172c6d53847694483644e48dfa663ab026dcab02ee6d30653c4673dcb5", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_state.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart", + "sha256": "87ef529118a7957f028426128361f2098656e9c7b7d84fbea17e6229614b6098", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart" + }, + { + "path": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart", + "sha256": "91b98671e643d1fa0013313cfe33d220e4e4a81e127904f86dc73dd02c63cc18", + "member": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart" + }, + { + "path": "lib/presentation/schedule_create/screens/schedule_create_screen.dart", + "sha256": "2db8bb7aaaa5b8f99221f1b700cf76e68419bc20f1c799e0b016a7e4d8f0a178", + "member": "lib/presentation/schedule_create/screens/schedule_create_screen.dart" + }, + { + "path": "lib/presentation/schedule_create/screens/schedule_edit_screen.dart", + "sha256": "6845a84e6464d1aacf24bb970bc2a64a3dbfcc2ffea98e7f4e27a45945b4160b", + "member": "lib/presentation/schedule_create/screens/schedule_edit_screen.dart" + }, + { + "path": "lib/presentation/schedule_create/screens/schedule_time_correction_screen.dart", + "sha256": "450ef6c0eaffc80d67d9b86ffc2505f5e67c51fba214ecd9c2edaf9ac24c4de7", + "member": "lib/presentation/schedule_create/screens/schedule_time_correction_screen.dart" + }, + { + "path": "lib/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart", + "sha256": "caae84e2f787c9a78f5090d31268d408e40e5875dd2ef475963f467716ebd3d8", + "member": "lib/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart" + }, + { + "path": "lib/presentation/shared/components/arc_indicator.dart", + "sha256": "ff2cdce39136e456503d4dfe4fc346f5e5898ccd03b0b4d6899c9384fbefdcba", + "member": "lib/presentation/shared/components/arc_indicator.dart" + }, + { + "path": "lib/presentation/shared/components/bottom_nav_bar_scaffold.dart", + "sha256": "509cf3de7cfbdfa0b9046f8b60ab966959df2f5fa1c344b1f0a2e1abd5f36d05", + "member": "lib/presentation/shared/components/bottom_nav_bar_scaffold.dart" + }, + { + "path": "lib/presentation/shared/components/bottom_navigation_bar.dart", + "sha256": "431ccb3643756b89a8bff46277ebdeda6c0d1d32fd273884a92076ff4c05bd00", + "member": "lib/presentation/shared/components/bottom_navigation_bar.dart" + }, + { + "path": "lib/presentation/shared/components/calendar/centered_calendar_header.dart", + "sha256": "65ff57550fd149fc24d918c388d1df021172b89279f0e7cea5e889e4d05f0a78", + "member": "lib/presentation/shared/components/calendar/centered_calendar_header.dart" + }, + { + "path": "lib/presentation/shared/components/calendar/schedule_marker_builder.dart", + "sha256": "af298fd331c94a632d23baf2875a16d0e4597318542bf36a346e8d16c68aba01", + "member": "lib/presentation/shared/components/calendar/schedule_marker_builder.dart" + }, + { + "path": "lib/presentation/shared/components/check_button.dart", + "sha256": "1bdf31d714bcb58a05f89f892297eda954898d53a35ea906294f3c02588822de", + "member": "lib/presentation/shared/components/check_button.dart" + }, + { + "path": "lib/presentation/shared/components/civil_date_time_picker.dart", + "sha256": "814f4eb12b7e8a648da8e0c79a6cee47bd2ce3414fe1cd10ce7affb2b609ed04", + "member": "lib/presentation/shared/components/civil_date_time_picker.dart" + }, + { + "path": "lib/presentation/shared/components/cupertino_picker_modal.dart", + "sha256": "fd0168081a9e39a75034ce4bc1ef970845b6a9e7aeadbc8110f652b535b1a8b8", + "member": "lib/presentation/shared/components/cupertino_picker_modal.dart" + }, + { + "path": "lib/presentation/shared/components/custom_alert_dialog.dart", + "sha256": "230968acf50a104b8ff93e338e838b09d74aca8a0ce70a5effa7ef0275a339f8", + "member": "lib/presentation/shared/components/custom_alert_dialog.dart" + }, + { + "path": "lib/presentation/shared/components/error_message_bubble.dart", + "sha256": "6e0014a4053f1be61b6eeffec795630eea8e85730141b141ea48d74c62a5b3c2", + "member": "lib/presentation/shared/components/error_message_bubble.dart" + }, + { + "path": "lib/presentation/shared/components/loading_screen.dart", + "sha256": "cfc1e7a30077bbf7aa24392abb06d05f1ee6a51d72de682b66f861bcfbe716dd", + "member": "lib/presentation/shared/components/loading_screen.dart" + }, + { + "path": "lib/presentation/shared/components/modal_wide_button.dart", + "sha256": "b669ff1e9de9e0c0086b635f1bd1b7b11515cf6ffa9cd268924dd43bd82ea492", + "member": "lib/presentation/shared/components/modal_wide_button.dart" + }, + { + "path": "lib/presentation/shared/components/notification_timing_education.dart", + "sha256": "bdfca87e3ba18234dfc41d7cb29ce4af6b1fe7c76828ac98aaf7efd7e4bd4207", + "member": "lib/presentation/shared/components/notification_timing_education.dart" + }, + { + "path": "lib/presentation/shared/components/step_progress.dart", + "sha256": "83090a7190890211e86532a41b58f6e042abe96540ea95a0dd02a1dec2cb413d", + "member": "lib/presentation/shared/components/step_progress.dart" + }, + { + "path": "lib/presentation/shared/components/tile.dart", + "sha256": "612f63b597ee5c12d76ea182f0bf397f9b6df0fe8594218099ac70d3cfdd0ef3", + "member": "lib/presentation/shared/components/tile.dart" + }, + { + "path": "lib/presentation/shared/components/time_stepper.dart", + "sha256": "f2f6bc1971d4ffad5bc92b4871599dc00b76cb97e103a950960514efeb84f615", + "member": "lib/presentation/shared/components/time_stepper.dart" + }, + { + "path": "lib/presentation/shared/components/two_action_dialog.dart", + "sha256": "d78a54e7cfe969e40939b740a9ba60dd3aef35c0b8202d97601ccefb57854095", + "member": "lib/presentation/shared/components/two_action_dialog.dart" + }, + { + "path": "lib/presentation/shared/components/two_button_delete_dialog.dart", + "sha256": "238b4218f13a91c9409f262b4682f59824360a6fd475e2d34db2329f69ef0acc", + "member": "lib/presentation/shared/components/two_button_delete_dialog.dart" + }, + { + "path": "lib/presentation/shared/constants/app_colors.dart", + "sha256": "dca7bf8e7b1a74b93ca87ac17635c6c0c5a731c41116884977539e20e2e78ef9", + "member": "lib/presentation/shared/constants/app_colors.dart" + }, + { + "path": "lib/presentation/shared/constants/constants.dart", + "sha256": "2b0b1a2d9130f201a675af89904a71ded3c056e70e24a557b825709c9165271b", + "member": "lib/presentation/shared/constants/constants.dart" + }, + { + "path": "lib/presentation/shared/constants/early_late_text_images.dart", + "sha256": "373b101f04cb658cdda21574dba94ab7b98d94eb0349c1e61eafa39ed6e5e13f", + "member": "lib/presentation/shared/constants/early_late_text_images.dart" + }, + { + "path": "lib/presentation/shared/router/app_route_transition.dart", + "sha256": "b727d9335c01475dfb8d3016c7dea72bf55906b701607feb00b800b0ae2d3212", + "member": "lib/presentation/shared/router/app_route_transition.dart" + }, + { + "path": "lib/presentation/shared/router/go_router.dart", + "sha256": "c5bb3bfc75e354f276446e1236e54b44bb87ad0c1f08ede8dade51efacf55680", + "member": "lib/presentation/shared/router/go_router.dart" + }, + { + "path": "lib/presentation/shared/router/route_arguments.dart", + "sha256": "f97531dad02c929ade1aa73a8a6df1eaa8a24313018b517ac0277f6ea32073ec", + "member": "lib/presentation/shared/router/route_arguments.dart" + }, + { + "path": "lib/presentation/shared/theme/button_styles.dart", + "sha256": "58f8fea4d7a728f387ced58b594d97db036774528f61509719f86a30bddcbe0c", + "member": "lib/presentation/shared/theme/button_styles.dart" + }, + { + "path": "lib/presentation/shared/theme/calendar_theme.dart", + "sha256": "33c85328ad77666dfdfa42dcc033dd2adfd06bcc4e3185881a03c0b7b576492f", + "member": "lib/presentation/shared/theme/calendar_theme.dart" + }, + { + "path": "lib/presentation/shared/theme/color_scheme.dart", + "sha256": "beffaaa6aacada4e982d1375aa87c9871a6d0201663be2831903bb5aa03eeaee", + "member": "lib/presentation/shared/theme/color_scheme.dart" + }, + { + "path": "lib/presentation/shared/theme/input_decoration_theme.dart", + "sha256": "cb571d5c7f0dca6f696f8227b5738c392a369b092853b8a05562e6989fdae193", + "member": "lib/presentation/shared/theme/input_decoration_theme.dart" + }, + { + "path": "lib/presentation/shared/theme/text_theme.dart", + "sha256": "0999af756973ba3af517333a7ba736bb92cc92ad1f687874467dde74f3e4ea65", + "member": "lib/presentation/shared/theme/text_theme.dart" + }, + { + "path": "lib/presentation/shared/theme/theme.dart", + "sha256": "abfe61d784665ae144e360a97aa811511fc6950f69335cd3bc2d3403f36571df", + "member": "lib/presentation/shared/theme/theme.dart" + }, + { + "path": "lib/presentation/shared/theme/tile_style.dart", + "sha256": "6d163a250d69adf3e0d340d9184daa1d65d9a789d11b46adff0a9430794bd686", + "member": "lib/presentation/shared/theme/tile_style.dart" + }, + { + "path": "lib/presentation/shared/time/date_basis_info.dart", + "sha256": "672881f485c6b4be77c7bfead3ea3c673c6235543fa5ef150f489dfeed1eaa97", + "member": "lib/presentation/shared/time/date_basis_info.dart" + }, + { + "path": "lib/presentation/shared/time/device_time_zone_scope.dart", + "sha256": "17e781eb25db1a7fa22b9a88189863553070c98614cf12d42967deafb31dcae6", + "member": "lib/presentation/shared/time/device_time_zone_scope.dart" + }, + { + "path": "lib/presentation/shared/time/device_time_zone_state.dart", + "sha256": "0cfebc68be4482b0db52100d8844d7aacc209f95de50b10e7373131989a894e0", + "member": "lib/presentation/shared/time/device_time_zone_state.dart" + }, + { + "path": "lib/presentation/shared/time/schedule_zoned_time.dart", + "sha256": "64931b5f71881646bc0a555e304ad5692a3282af008b136034d654286be37591", + "member": "lib/presentation/shared/time/schedule_zoned_time.dart" + }, + { + "path": "lib/presentation/shared/time/schedule_zoned_time_text.dart", + "sha256": "16aeca6edfc79f568d2c0102357c1b59747e971739ae39d42423d7f2f1899ff2", + "member": "lib/presentation/shared/time/schedule_zoned_time_text.dart" + }, + { + "path": "lib/presentation/shared/time/time_zone_catalog.dart", + "sha256": "e81f4c626e6a20cee98b5612b844e0539c65eff9811407277eec2b9531501215", + "member": "lib/presentation/shared/time/time_zone_catalog.dart" + }, + { + "path": "lib/presentation/shared/time/zoned_schedule_presentation.dart", + "sha256": "a438ea989851f3890bba0cf082ea350cc853e2d87602b7a35daf253395c888fa", + "member": "lib/presentation/shared/time/zoned_schedule_presentation.dart" + }, + { + "path": "lib/presentation/shared/utils/duration_format.dart", + "sha256": "f8812701bda47d580dfaff36f4b790494ddcffd41a8893dfcabcdbe647b8103c", + "member": "lib/presentation/shared/utils/duration_format.dart" + }, + { + "path": "lib/presentation/shared/utils/login_platform.dart", + "sha256": "07ecf92084bfd790c0266c576d95bfec66de4712becb949b69fb070f0458cff3", + "member": "lib/presentation/shared/utils/login_platform.dart" + }, + { + "path": "lib/presentation/shared/utils/stream_to_listenable.dart", + "sha256": "daf2bebb059f45dd57511a3fa57ced28d70ae53348924fefc0bf89e9dccf41d3", + "member": "lib/presentation/shared/utils/stream_to_listenable.dart" + }, + { + "path": "lib/presentation/shared/utils/time_format.dart", + "sha256": "83a824385d87fedd9d59d3d644fb6286803b8121fbe8d8e2d61041d1294573e1", + "member": "lib/presentation/shared/utils/time_format.dart" + }, + { + "path": "lib/presentation/startup/screens/local_data_recovery_screen.dart", + "sha256": "b2a791cca2339b0df1a8c58c71cdd56084459cb2506d59e196bdd5b135714f1e", + "member": "lib/presentation/startup/screens/local_data_recovery_screen.dart" + }, + { + "path": "lib/presentation/startup/screens/local_reset_progress_screen.dart", + "sha256": "22f38e6e93a9b07742a49926977acac4532973ad20abd6203137252550dad8f8", + "member": "lib/presentation/startup/screens/local_reset_progress_screen.dart" + }, + { + "path": "lib/presentation/startup/screens/local_startup_gate.dart", + "sha256": "194720e82ef244c719471b09a88e0c4f2fe90f4d5207c2dc59d30469d9c0a2ac", + "member": "lib/presentation/startup/screens/local_startup_gate.dart" + }, + { + "path": "lib/presentation/startup/screens/recovery_restore_screen.dart", + "sha256": "daff784bcec0f6092aa5bebf7bc77b5e5317e6cc3199246f56865f12affdaf7c", + "member": "lib/presentation/startup/screens/recovery_restore_screen.dart" + }, + { + "path": "lib/presentation/startup/screens/startup_screen.dart", + "sha256": "7a8475e5edb9be5e557f0b6f9191d2b023d60857fd3c23460c1bce73a9f72af2", + "member": "lib/presentation/startup/screens/startup_screen.dart" + }, + { + "path": "pubspec.lock", + "sha256": "3dd6381afe1250a3f9a697e591820090d390cb696e04ec3da6ac6f731e973611", + "member": "pubspec.lock" + }, + { + "path": "pubspec.yaml", + "sha256": "39f541cfb7e29e91c90af9808ff64fde50f471c26fd738fa2617b6f78f254f12", + "member": "pubspec.yaml" + }, + { + "path": "test/core/backup/backup_admission_test.dart", + "sha256": "25167ed87e9aca4d23b7b98d191d40a2287fd6744f2b31229871e6f6a5b774bd", + "member": "test/core/backup/backup_admission_test.dart" + }, + { + "path": "test/core/backup/backup_authenticated_time_review_test.dart", + "sha256": "7da7dd58d8844791593a09c7512d853e9782a657ae2c19973d320d22653a656b", + "member": "test/core/backup/backup_authenticated_time_review_test.dart" + }, + { + "path": "test/core/backup/backup_civil_persistence_test.dart", + "sha256": "984e5bae916f42fb9e06d1fecd0c3ff6c6d27a856262c246ffb8bce904eb56e0", + "member": "test/core/backup/backup_civil_persistence_test.dart" + }, + { + "path": "test/core/backup/backup_crypto_capacity_test.dart", + "sha256": "ae80ddd6f8c064ae63ebbcdf5456108178e216e3ec3cae5daabf12658e8506fe", + "member": "test/core/backup/backup_crypto_capacity_test.dart" + }, + { + "path": "test/core/backup/backup_crypto_failure_matrix_test.dart", + "sha256": "fbed3da5afb88fdae42ef5068fa2d7b7f86c950d112b14600301dad0ddbf49cb", + "member": "test/core/backup/backup_crypto_failure_matrix_test.dart" + }, + { + "path": "test/core/backup/backup_crypto_stream_test.dart", + "sha256": "dc062c641bd563289eebfcbea99b7c7b72f4bf18777987fa89075d7471a025da", + "member": "test/core/backup/backup_crypto_stream_test.dart" + }, + { + "path": "test/core/backup/backup_crypto_test.dart", + "sha256": "919b76c76e3a981bd6f07fea477e328e940c8e542b2488e8453f78abc819d023", + "member": "test/core/backup/backup_crypto_test.dart" + }, + { + "path": "test/core/backup/backup_export_snapshot_test.dart", + "sha256": "8f2093c669032d3efd7c635515f30b9e69f6253781a636744880bd41c37a2e67", + "member": "test/core/backup/backup_export_snapshot_test.dart" + }, + { + "path": "test/core/backup/backup_failure_retry_acceptance_test.dart", + "sha256": "5f7ca795001dab7e8fbd4ba6451923ebdd4722f918d5b7604d425236ff7b2ebe", + "member": "test/core/backup/backup_failure_retry_acceptance_test.dart" + }, + { + "path": "test/core/backup/backup_file_export_port_test.dart", + "sha256": "0181d8bd22c8430139be4c593410b0325556338248ebeecd918281f94db3db78", + "member": "test/core/backup/backup_file_export_port_test.dart" + }, + { + "path": "test/core/backup/backup_file_import_port_test.dart", + "sha256": "43f284cde258a3652a84eb47d4f0b3b49ae13041586ec046d65e2b10df6e9644", + "member": "test/core/backup/backup_file_import_port_test.dart" + }, + { + "path": "test/core/backup/backup_ingestion_store_test.dart", + "sha256": "db8aaed4173d44bacc5d0e2318cfe246bc64ad585f276c1531eee5c7db7a47e1", + "member": "test/core/backup/backup_ingestion_store_test.dart" + }, + { + "path": "test/core/backup/backup_instant_representation_test.dart", + "sha256": "e1c193e612470daa93efc3eb48d5b89cbada18606cda949b1cd075fa9672bc84", + "member": "test/core/backup/backup_instant_representation_test.dart" + }, + { + "path": "test/core/backup/backup_json_reader_test.dart", + "sha256": "b72e2c061e9d69ffea499ccf67515a8fbb593b9a45c17ffccd824575337b8906", + "member": "test/core/backup/backup_json_reader_test.dart" + }, + { + "path": "test/core/backup/backup_large_ingestion_test.dart", + "sha256": "f93106fbb6d6d5fbe0fbef519499edd93b8d98b30412683ba56320eed685fd88", + "member": "test/core/backup/backup_large_ingestion_test.dart" + }, + { + "path": "test/core/backup/backup_parser_budget_acceptance_test.dart", + "sha256": "0b8a731d8f738a668604b67efe4b6beecaa1af4229440ffaceca550b4ca29e20", + "member": "test/core/backup/backup_parser_budget_acceptance_test.dart" + }, + { + "path": "test/core/backup/backup_password_test.dart", + "sha256": "d4f2b255bf8cab46994ecfaf24e4f639de21e474b3bfea486459e4650caf0108", + "member": "test/core/backup/backup_password_test.dart" + }, + { + "path": "test/core/backup/backup_recurring_time_review_cipher_test.dart", + "sha256": "4c565a4d1fa1b3aaede1109b2b618f94864cd12d91472184d920f6dab26b349c", + "member": "test/core/backup/backup_recurring_time_review_cipher_test.dart" + }, + { + "path": "test/core/backup/backup_recurring_time_review_test.dart", + "sha256": "d514835fd3bf610439a33340490bac6f58b047743aae6aaef99ac4d870b355b6", + "member": "test/core/backup/backup_recurring_time_review_test.dart" + }, + { + "path": "test/core/backup/backup_restore_picker_test.dart", + "sha256": "0416f361e5f0189f1f95b22fb1d0bc0039cf5dc8199fa9a6faa9507be1dea052", + "member": "test/core/backup/backup_restore_picker_test.dart" + }, + { + "path": "test/core/backup/backup_restore_stale_contract_test.dart", + "sha256": "a0b3cbfd04a0b6d5b1af7990cef977ae49a696fe4e95d51e317404dda3a49a3c", + "member": "test/core/backup/backup_restore_stale_contract_test.dart" + }, + { + "path": "test/core/backup/backup_retained_recurrence_test.dart", + "sha256": "3d7fc2a201120f3acdd86c9cd96fdca0a0332ad610a5d9667ef154d360a7728e", + "member": "test/core/backup/backup_retained_recurrence_test.dart" + }, + { + "path": "test/core/backup/backup_service_test.dart", + "sha256": "de17ef58ceb43a0727db7a4f47db53862149ef3a6d5f87c355ad0da32ccf753b", + "member": "test/core/backup/backup_service_test.dart" + }, + { + "path": "test/core/backup/backup_spare_time_boundary_test.dart", + "sha256": "351d4ad2303366e996ad47220b6a1ea24cc255ee50f69845b4d77d4c2f9333c0", + "member": "test/core/backup/backup_spare_time_boundary_test.dart" + }, + { + "path": "test/core/backup/backup_time_review_service_test.dart", + "sha256": "8a10b3a116c49f500083c98c0b96a710389b7a690066164b971219355646c6b6", + "member": "test/core/backup/backup_time_review_service_test.dart" + }, + { + "path": "test/core/backup/backup_unknown_history_test.dart", + "sha256": "650fba49d6afc1109bbd213354b97d100f771107b68a0cb73150af37d2a541af", + "member": "test/core/backup/backup_unknown_history_test.dart" + }, + { + "path": "test/core/backup/backup_unknown_recurrence_test.dart", + "sha256": "eef04fe887f32ea7a825888183c877e05baaff88d32b2a1215ff58af0d10a5b5", + "member": "test/core/backup/backup_unknown_recurrence_test.dart" + }, + { + "path": "test/core/backup/backup_validated_ingestion_test.dart", + "sha256": "2e608b9020fa10359ed8ff56c1cbaf14b6cfe694725f26f3550cd41ece7d6c3c", + "member": "test/core/backup/backup_validated_ingestion_test.dart" + }, + { + "path": "test/core/backup/backup_validation_contract_test.dart", + "sha256": "c35716234841ce91ac42106a233c9a0d7451c52bab5f7f9266746440a805788a", + "member": "test/core/backup/backup_validation_contract_test.dart" + }, + { + "path": "test/core/backup/detailed_notification_backup_generation_acceptance_test.dart", + "sha256": "19b7b078c6dde97a7cef4146f85dabe1e51aecfded982f4cef49124ffd46aa91", + "member": "test/core/backup/detailed_notification_backup_generation_acceptance_test.dart" + }, + { + "path": "test/core/backup/restore_commit_response_acceptance_test.dart", + "sha256": "0fa42372bea66102fc7581dd656037dd84747973f89d3e89b961291636d251e6", + "member": "test/core/backup/restore_commit_response_acceptance_test.dart" + }, + { + "path": "test/core/backup/restore_replacement_protocol_test.dart", + "sha256": "f53afb269d2f57e2e380fb9127b8d947d1345f2dbcb289437560334b83ec70dd", + "member": "test/core/backup/restore_replacement_protocol_test.dart" + }, + { + "path": "test/core/backup/restore_staging_cipher_test.dart", + "sha256": "840236ef7a3dd91cf82a1490e52852805400d80050bdbf42c6c5eb60c091ec76", + "member": "test/core/backup/restore_staging_cipher_test.dart" + }, + { + "path": "test/core/backup/schedule_deletion_backup_test.dart", + "sha256": "b52f4f54e2fe2869989211255f8e9c1a005975f3d07ff22c307128f32f667b47", + "member": "test/core/backup/schedule_deletion_backup_test.dart" + }, + { + "path": "test/core/database/alarm_journal_recovery_test.dart", + "sha256": "d4861dbaea522c2811872172a51df80d8a4316d7519f9d2acc6699f5898c5c09", + "member": "test/core/database/alarm_journal_recovery_test.dart" + }, + { + "path": "test/core/database/alarm_replacement_integration_test.dart", + "sha256": "eb2ed4ca24d057dc9bfdebe5aad27c9341b240fc1ca2108a1c2db9260c71b249", + "member": "test/core/database/alarm_replacement_integration_test.dart" + }, + { + "path": "test/core/database/bootstrap_privacy_boundary_test.dart", + "sha256": "3a7246a1a4ff4042c1d31fc5fc60545885fb5ae3c1da833c32eff9b3c1a03847", + "member": "test/core/database/bootstrap_privacy_boundary_test.dart" + }, + { + "path": "test/core/database/cipher_capability_guard_test.dart", + "sha256": "96d260284d7102999206e75c704567934462b6b41dbdb34a057a2b7588612e10", + "member": "test/core/database/cipher_capability_guard_test.dart" + }, + { + "path": "test/core/database/database_index_test.dart", + "sha256": "6520060bba808fbc25fb7784b869f299429da2075540da1e5a1922c09e7aff13", + "member": "test/core/database/database_index_test.dart" + }, + { + "path": "test/core/database/encrypted_schema_roles_test.dart", + "sha256": "cb244c44885eeb92a68c869318e4f895ba1bbe162c6948e8f3f793027d622242", + "member": "test/core/database/encrypted_schema_roles_test.dart" + }, + { + "path": "test/core/database/historical_row_image_test.dart", + "sha256": "9eeccb579d57b1a11a8810d9ea84bd853a332ab381ed57d07b1b35193db33aa8", + "member": "test/core/database/historical_row_image_test.dart" + }, + { + "path": "test/core/database/initial_store_cipher_test.dart", + "sha256": "4160cc793a11bfa511be7200a81fd550986a6859819358b2a9ec5be2ff9ef1f2", + "member": "test/core/database/initial_store_cipher_test.dart" + }, + { + "path": "test/core/database/initial_store_guard_test.dart", + "sha256": "f7a441efd2a7ca08ae49fb390b7b77db7b7327091f60378f51d94177fdb7bab4", + "member": "test/core/database/initial_store_guard_test.dart" + }, + { + "path": "test/core/database/local_data_lifecycle_startup_test.dart", + "sha256": "b3cd8a53b0b2096498af14a3998a68162674528c5f2656374ff292748cf8f977", + "member": "test/core/database/local_data_lifecycle_startup_test.dart" + }, + { + "path": "test/core/database/local_data_operation_gate_test.dart", + "sha256": "3e8d5b1e105a774364b48060abed94d5c2851f9c9b86e95d0d2758f593647ad8", + "member": "test/core/database/local_data_operation_gate_test.dart" + }, + { + "path": "test/core/database/local_reset_actions_test.dart", + "sha256": "ee94f2185ac85c097c3f8ae1212c261d7f2e14e71f793378a9806faabb275716", + "member": "test/core/database/local_reset_actions_test.dart" + }, + { + "path": "test/core/database/local_reset_protocol_test.dart", + "sha256": "4d5f256dbb29dba29fc6b79b0da6bdd4c7024e2edfdbe19be9cee18fe465a99b", + "member": "test/core/database/local_reset_protocol_test.dart" + }, + { + "path": "test/core/database/migration_atomic_boundary_test.dart", + "sha256": "8b72dc05ad4e935bb3f9d4c61bef8f9c14b7f9daafed69e1a30841e7b1b85051", + "member": "test/core/database/migration_atomic_boundary_test.dart" + }, + { + "path": "test/core/database/migration_checkpoint_test.dart", + "sha256": "e61fcc918fab6e567d38df19becd6034c8d269093a85eaa3d7dcdb0b96c0d3f8", + "member": "test/core/database/migration_checkpoint_test.dart" + }, + { + "path": "test/core/database/migration_sqlite_fault_test.dart", + "sha256": "9f7a677ce4bc45776fd687e6421c44b12b2dcd594053143118ad46d39148852a", + "member": "test/core/database/migration_sqlite_fault_test.dart" + }, + { + "path": "test/core/database/native_process_identity_test.dart", + "sha256": "9e47f2c7ccc1a456785f3c98d1d75681b1d79546a39a688624e44bb0a60f7460", + "member": "test/core/database/native_process_identity_test.dart" + }, + { + "path": "test/core/database/recovery_pair_authority_test.dart", + "sha256": "ad0d931e3ce07ce917b8fa804e160c40b74a25ddaff0198d4379c5a16ea085b1", + "member": "test/core/database/recovery_pair_authority_test.dart" + }, + { + "path": "test/core/database/recovery_restore_service_test.dart", + "sha256": "3d80262b7a7159b6d8b18608048706b66bd799672f04b0861aaae9dc36d31f18", + "member": "test/core/database/recovery_restore_service_test.dart" + }, + { + "path": "test/core/database/recovery_time_review_service_test.dart", + "sha256": "7d855cde486bebe756de0dd381ec263107dcbee056a8f0596805e067da140add", + "member": "test/core/database/recovery_time_review_service_test.dart" + }, + { + "path": "test/core/database/recurring_migration_test.dart", + "sha256": "03a0745c00ea247c55dea9f65b27a435b0492b217520c0f75fab4a898b672c0d", + "member": "test/core/database/recurring_migration_test.dart" + }, + { + "path": "test/core/database/restore_delivery_cleanup_test.dart", + "sha256": "b71d8211db28814742996c240ac18b7d20a26f99248961d236d3151c1fc9cabf", + "member": "test/core/database/restore_delivery_cleanup_test.dart" + }, + { + "path": "test/core/database/restore_runtime_quiescence_test.dart", + "sha256": "4aabda1d99ddc9bdd50d2fc1e0bad4bace78b35afe0a979a4080e3a450ba4ff1", + "member": "test/core/database/restore_runtime_quiescence_test.dart" + }, + { + "path": "test/core/database/restore_startup_classification_test.dart", + "sha256": "6792cee4f9ef369a1aab46d8d93f0ccba0ef16e12deba4b40c41c25a48164626", + "member": "test/core/database/restore_startup_classification_test.dart" + }, + { + "path": "test/core/database/schedule_aggregate_migration_test.dart", + "sha256": "695df4af77cfdd35e58945ba62c2337047c2759b7c131359bea9fdc54c6a0652", + "member": "test/core/database/schedule_aggregate_migration_test.dart" + }, + { + "path": "test/core/database/schema_contract_test.dart", + "sha256": "960a3475adc0cef6c7566957d2601d116bf6b89c55cedafe2080c7329a55ae90", + "member": "test/core/database/schema_contract_test.dart" + }, + { + "path": "test/core/database/start_restore_generation_acceptance_test.dart", + "sha256": "40d527f6f2bd70218a5c81748c9ae1741351fbf9e2c5cba18882b3844b517678", + "member": "test/core/database/start_restore_generation_acceptance_test.dart" + }, + { + "path": "test/core/logging/app_logger_test.dart", + "sha256": "342b817a203e2c4e75326da5bdac5155c42a3fb9494c2c28fa40e23f24656a9c", + "member": "test/core/logging/app_logger_test.dart" + }, + { + "path": "test/core/services/alarm_ownership_journal_test.dart", + "sha256": "c905fdb82a0630994568acf3ffab03d94bd2dbd304946f6a563a45784086d94b", + "member": "test/core/services/alarm_ownership_journal_test.dart" + }, + { + "path": "test/core/services/alarm_scheduler_service_test.dart", + "sha256": "17dee952d6edaf94e5df81fd8839fe3d162aaebb72f521c8a959e51d949b7c34", + "member": "test/core/services/alarm_scheduler_service_test.dart" + }, + { + "path": "test/core/services/android_notification_timing_test.dart", + "sha256": "f2e9032bf1be099fbf36d8ddf1a6bc29d0763ebd7de3ee2c1bed4f7d086ed168", + "member": "test/core/services/android_notification_timing_test.dart" + }, + { + "path": "test/core/services/app_metadata_service_test.dart", + "sha256": "fb0c5dde9142829d5a3ffd8df74f766a3de9317ba0dc1c7d6f6928a6ddb427ad", + "member": "test/core/services/app_metadata_service_test.dart" + }, + { + "path": "test/core/services/civil_legacy_runtime_migration_test.dart", + "sha256": "0996c9ceb3da8a57ae1bec1fc16191f84c7cdacdd10108cbe51e820410bce86f", + "member": "test/core/services/civil_legacy_runtime_migration_test.dart" + }, + { + "path": "test/core/services/delivery_observation_test.dart", + "sha256": "c249f8c99a5be63d831db724418f8dbc6c4bbdc518815afcb840445db2412e09", + "member": "test/core/services/delivery_observation_test.dart" + }, + { + "path": "test/core/services/detailed_notification_preference_acceptance_test.dart", + "sha256": "eac7648cebd3930a56d7df044865e59b5ae1c9ccee0dab86495265c9ec4b379e", + "member": "test/core/services/detailed_notification_preference_acceptance_test.dart" + }, + { + "path": "test/core/services/fallback_alarm_notification_service_test.dart", + "sha256": "481b16728461a7eb8bd2fc9c470fac09d612f50da18e6799dc180843bf744381", + "member": "test/core/services/fallback_alarm_notification_service_test.dart" + }, + { + "path": "test/core/services/notification_content_test.dart", + "sha256": "800b7a336f82befc9d9c11f7c4e107fd71c23b6c86353f0d9c30af7207c6ddc0", + "member": "test/core/services/notification_content_test.dart" + }, + { + "path": "test/core/services/notification_launch_test.dart", + "sha256": "a8c8eea53276b67aaf6060dccb952dc112631f19b1ee96bc9630bb5208225cbe", + "member": "test/core/services/notification_launch_test.dart" + }, + { + "path": "test/core/services/notification_privacy_cleanup_test.dart", + "sha256": "31c0a8a40125671588d71cbadcc039a4e6cf5f6ea87539d4259f0a09fd6121a1", + "member": "test/core/services/notification_privacy_cleanup_test.dart" + }, + { + "path": "test/core/services/notification_routing_test.dart", + "sha256": "2077b8a15d9c1cca27e50fb287da7edc481930164de8df28d7f13080be7a05f9", + "member": "test/core/services/notification_routing_test.dart" + }, + { + "path": "test/core/services/notification_tap_router_test.dart", + "sha256": "1ee0b7878694c0ef67d9f0f76d264b1adacd18ffc4383997ce95c95438c0e21d", + "member": "test/core/services/notification_tap_router_test.dart" + }, + { + "path": "test/core/services/preparation_step_notification_test.dart", + "sha256": "5a9d45fe439c38ee00471e90fa849e7d9be65160e5f898aaa4bc1696679dcea7", + "member": "test/core/services/preparation_step_notification_test.dart" + }, + { + "path": "test/core/services/runtime_privacy_migration_test.dart", + "sha256": "2e106d058b8b33386006794bc46a05fe497f87fd8e416eb6156ac50482abe4b5", + "member": "test/core/services/runtime_privacy_migration_test.dart" + }, + { + "path": "test/core/services/scheduled_notification_delivery_test.dart", + "sha256": "cf00c6682f12b9d958af22c960f3ba9bac506e08972d0679638af621d61a2341", + "member": "test/core/services/scheduled_notification_delivery_test.dart" + }, + { + "path": "test/core/startup/startup_dependency_scope_test.dart", + "sha256": "2471e336f2be16ba18a1d436d5880d469dc7c649fa24de7505c6901c5be15844", + "member": "test/core/startup/startup_dependency_scope_test.dart" + }, + { + "path": "test/core/startup/startup_product_graph_test.dart", + "sha256": "af3c6f8c574d68e8e2cf275c41f9e9c592d65b88d82bccedcea4c705acdd433d", + "member": "test/core/startup/startup_product_graph_test.dart" + }, + { + "path": "test/core/startup/subscription_cleanup_test.dart", + "sha256": "2b7fa4bd76b948733a4abd411849c4a09d9de66da06e90263c00150eefa3fd82", + "member": "test/core/startup/subscription_cleanup_test.dart" + }, + { + "path": "test/core/time/civil_time_resolver_test.dart", + "sha256": "3de964d1dcb95a46cdc29fce29dabc9a9c2c18f10faca3f80dfd94dceb5b45d2", + "member": "test/core/time/civil_time_resolver_test.dart" + }, + { + "path": "test/core/time/device_civil_day_test.dart", + "sha256": "72d73ab62ca91a96a19098142ef19279e334d22f219d3cbf860b05b0cc42e198", + "member": "test/core/time/device_civil_day_test.dart" + }, + { + "path": "test/core/time/recurring_time_correction_mapping_test.dart", + "sha256": "872d161b72fb116b7fb2e1e7fad5b5e416fd1cfdaa955ed180d000e332676398", + "member": "test/core/time/recurring_time_correction_mapping_test.dart" + }, + { + "path": "test/core/time/schedule_time_resolution_test.dart", + "sha256": "02b4cf1a98dc3275d1674a1c928564bd877831fd2858593f4fffeba106c56809", + "member": "test/core/time/schedule_time_resolution_test.dart" + }, + { + "path": "test/core/time/time_correction_conflicts_test.dart", + "sha256": "232e6da7eefee948a4ea4bf034888cc79572756097d0edd3d4635b9a9139f298", + "member": "test/core/time/time_correction_conflicts_test.dart" + }, + { + "path": "test/core/time/time_zone_rules_test.dart", + "sha256": "0fcd234ba2a61d0e50eae63250da9b3605e074f386267aab1e7809f1b1c692fd", + "member": "test/core/time/time_zone_rules_test.dart" + }, + { + "path": "test/data/daos/civil_schedule_storage_test.dart", + "sha256": "d449c685d0786d61c7ec2b65520e917721f23766d780d8397708c9cf8e24b269", + "member": "test/data/daos/civil_schedule_storage_test.dart" + }, + { + "path": "test/data/daos/preparation_schedule_dao_test.dart", + "sha256": "9f613abafb9c971d62057747b2dbe9d8591d92f90d06928d5f63a53006c723ca", + "member": "test/data/daos/preparation_schedule_dao_test.dart" + }, + { + "path": "test/data/daos/preparation_user_dao_test.dart", + "sha256": "cb2ce4d5bf10dc4b42e05e1d45806cf7a90e8140cbef8b14e35fec141987753c", + "member": "test/data/daos/preparation_user_dao_test.dart" + }, + { + "path": "test/data/daos/schedule_dao_test.dart", + "sha256": "608c92beef5e140dc9f3d894f0136f0a8d72685f53fd948cdeacc640ac93671e", + "member": "test/data/daos/schedule_dao_test.dart" + }, + { + "path": "test/data/daos/user_dao_test.dart", + "sha256": "c2ad0692c4563e65ac6881c6cf7bf8a7e1dd3af216ce3b5001168d40c9d6c88b", + "member": "test/data/daos/user_dao_test.dart" + }, + { + "path": "test/data/data_sources/alarm_registry_local_data_source_test.dart", + "sha256": "7809bb208b3ab05ee2bcc55581bd9e0141d8158888f9291c27c12f1b15b2d79b", + "member": "test/data/data_sources/alarm_registry_local_data_source_test.dart" + }, + { + "path": "test/data/data_sources/early_start_session_local_data_source_test.dart", + "sha256": "eff79d3b94bf9a1c368a24ef35272bcb9487e2d16c9e90496e7b6b8c12060907", + "member": "test/data/data_sources/early_start_session_local_data_source_test.dart" + }, + { + "path": "test/data/data_sources/preparation_local_data_source_test.dart", + "sha256": "d747510a56dec3a8557ea69649c74b8a6f593d02921afe54c918a4391ea238c8", + "member": "test/data/data_sources/preparation_local_data_source_test.dart" + }, + { + "path": "test/data/data_sources/preparation_with_time_local_data_source_test.dart", + "sha256": "15031e33843abc333409884ebd25122455766e6cbd6cd991df7488580006d7f3", + "member": "test/data/data_sources/preparation_with_time_local_data_source_test.dart" + }, + { + "path": "test/data/data_sources/schedule_data_source_contract_test.dart", + "sha256": "e25dffb2fd359ca69c420a326a021c56c3d884516a0ad9d36047f8664231d41a", + "member": "test/data/data_sources/schedule_data_source_contract_test.dart" + }, + { + "path": "test/data/data_sources/schedule_local_data_source_test.dart", + "sha256": "fd89f2a82185fef88944b5a6238f30d69b64560cff4040df55ed8cda17cc6695", + "member": "test/data/data_sources/schedule_local_data_source_test.dart" + }, + { + "path": "test/data/mappers/domain_persistence_mappers_test.dart", + "sha256": "209d29badab3ac9c19751666ad303da266ea1e6a1efa0dd114bb28c6bd71ac6a", + "member": "test/data/mappers/domain_persistence_mappers_test.dart" + }, + { + "path": "test/data/repositories/alarm_registry_repository_impl_test.dart", + "sha256": "0b8060ced5059b4590a836c7eda138da3e4986c47d47b20d3518858f44524360", + "member": "test/data/repositories/alarm_registry_repository_impl_test.dart" + }, + { + "path": "test/data/repositories/default_preferences_profile_publication_test.dart", + "sha256": "a54e770395224ca7be1c120f16cda8872e68dec07e147a051abf746c3e0b4a3b", + "member": "test/data/repositories/default_preferences_profile_publication_test.dart" + }, + { + "path": "test/data/repositories/default_preferences_repository_test.dart", + "sha256": "fe69ee480d60013eab64a558f5c41b12e226b70c9d8d1c82ba9844ffe57bc5c9", + "member": "test/data/repositories/default_preferences_repository_test.dart" + }, + { + "path": "test/data/repositories/early_start_session_repository_impl_test.dart", + "sha256": "822f070d8f7baf793a11199065c9aedbd9817c836b74e150a47541f4e6f0c8e5", + "member": "test/data/repositories/early_start_session_repository_impl_test.dart" + }, + { + "path": "test/data/repositories/first_start_time_capture_test.dart", + "sha256": "33a5ff9da0954225f96caed18f551cfe7a3c9be3d066b2f0e9413da86077bc0f", + "member": "test/data/repositories/first_start_time_capture_test.dart" + }, + { + "path": "test/data/repositories/local_schedule_score_test.dart", + "sha256": "8d2ae57c2cc07ccb2dd8e01380887c915bcadf336ca22852a9b4c961ec1b5ad9", + "member": "test/data/repositories/local_schedule_score_test.dart" + }, + { + "path": "test/data/repositories/nearest_automatic_start_guard_test.dart", + "sha256": "54c226b537664f0a1865fc07db67cb40a1fc6a3c0ffdb25e5863d7191d46a6b8", + "member": "test/data/repositories/nearest_automatic_start_guard_test.dart" + }, + { + "path": "test/data/repositories/nearest_query_sql_boundaries_test.dart", + "sha256": "a8f9ab4d172329a89aeb745ce3dcc6b5132b12b904724687097179d28537fa56", + "member": "test/data/repositories/nearest_query_sql_boundaries_test.dart" + }, + { + "path": "test/data/repositories/nearest_schedule_query_repository_test.dart", + "sha256": "bd6e698918798c8ab1c0de632da53ba553c143a7d71de1f9195dd5bae38778fb", + "member": "test/data/repositories/nearest_schedule_query_repository_test.dart" + }, + { + "path": "test/data/repositories/preparation_restore_watch_test.dart", + "sha256": "aa98fa84a559410f7250f7fd1e9213f833146a7d440e18f2190f9fabe814c91a", + "member": "test/data/repositories/preparation_restore_watch_test.dart" + }, + { + "path": "test/data/repositories/profile_write_preservation_test.dart", + "sha256": "4692bce718b458de63dacd2ae933dddc05ef2b3fc44e1bded2f274d9d5b2b2f6", + "member": "test/data/repositories/profile_write_preservation_test.dart" + }, + { + "path": "test/data/repositories/recurring_schedule_repository_test.dart", + "sha256": "e0e2b8bbd124232e8c51e8974dc058cfb70ebbbf5968d6509a20a37a25490fcb", + "member": "test/data/repositories/recurring_schedule_repository_test.dart" + }, + { + "path": "test/data/repositories/recurring_time_correction_repository_test.dart", + "sha256": "4f4283a9cd76654bde11ae0dd6f777e7b45ca49ed68662f1eea1978180ecd9e1", + "member": "test/data/repositories/recurring_time_correction_repository_test.dart" + }, + { + "path": "test/data/repositories/recurring_time_override_test.dart", + "sha256": "002d28e7a6a47b9a5587188544b70fb911c0937db6d972448d99e018f390b5ba", + "member": "test/data/repositories/recurring_time_override_test.dart" + }, + { + "path": "test/data/repositories/schedule_aggregate_repository_test.dart", + "sha256": "dfa8ce81dd9f8f54fd3bdcd00354c88c921d37c615684fdf984942e47a2be763", + "member": "test/data/repositories/schedule_aggregate_repository_test.dart" + }, + { + "path": "test/data/repositories/schedule_aggregate_snapshot_test.dart", + "sha256": "c553033e0e3c0cff8a2d97a61771654cdae2e9643b79f9b40216dd1b4c31debe", + "member": "test/data/repositories/schedule_aggregate_snapshot_test.dart" + }, + { + "path": "test/data/repositories/schedule_deletion_privacy_test.dart", + "sha256": "f1d3442661bc4ef56406c932d6a8b5fb9099ef03098632e371ee4ce3e14f20c3", + "member": "test/data/repositories/schedule_deletion_privacy_test.dart" + }, + { + "path": "test/data/repositories/schedule_deletion_repository_test.dart", + "sha256": "af76539e97134a6a5cd16725630807c506c16075a099b2d2b97d6e876e866340", + "member": "test/data/repositories/schedule_deletion_repository_test.dart" + }, + { + "path": "test/data/repositories/schedule_restore_watch_test.dart", + "sha256": "751ee222e28f639868892b92a98c5f8c913bea2efc2bd92af2b7cb81ae5a5647", + "member": "test/data/repositories/schedule_restore_watch_test.dart" + }, + { + "path": "test/data/repositories/schedule_start_transaction_test.dart", + "sha256": "36f2a695104c12067b438d99df0136ed805f5046d9a5761f8c35802a23d4618f", + "member": "test/data/repositories/schedule_start_transaction_test.dart" + }, + { + "path": "test/data/repositories/schedule_time_correction_repository_test.dart", + "sha256": "ca4cedc44d92aeedda7e24ac3856facc9daeae234cebad22ef43985adf34ce07", + "member": "test/data/repositories/schedule_time_correction_repository_test.dart" + }, + { + "path": "test/data/repositories/schedule_time_review_commit_test.dart", + "sha256": "40f5d598fb5b5fa361bf45970fa26faa0dd63db3ed227661210f8b27d5eabcc1", + "member": "test/data/repositories/schedule_time_review_commit_test.dart" + }, + { + "path": "test/data/repositories/single_time_correction_conflict_test.dart", + "sha256": "ceb6202b475633bbdbb8ce99076b234c815fc6b582b239e800536c93bf21f39a", + "member": "test/data/repositories/single_time_correction_conflict_test.dart" + }, + { + "path": "test/data/repositories/time_correction_conflict_reader_test.dart", + "sha256": "4ebec460bd71aa3a76c2dbdd8f67a75cbfaeacc61ddb4e02ce3e73098e709d8d", + "member": "test/data/repositories/time_correction_conflict_reader_test.dart" + }, + { + "path": "test/data/repositories/unknown_historical_recurrence_materialization_test.dart", + "sha256": "956c7e61e2585db70a1c65549f8530fa3bc9b44e2898c915584879c6977ab581", + "member": "test/data/repositories/unknown_historical_recurrence_materialization_test.dart" + }, + { + "path": "test/data/repositories/user_restore_identity_test.dart", + "sha256": "b159a471c01b4b7996098f631bcbd4655d69ce8c2720092530b85d43bd73b221", + "member": "test/data/repositories/user_restore_identity_test.dart" + }, + { + "path": "test/domain/entities/alarm_delivery_policy_test.dart", + "sha256": "10fa2dc88c6600c8e6eac1c4b3065cfc6d0ed22bcf8db413f9b48dd4cb8c0afd", + "member": "test/domain/entities/alarm_delivery_policy_test.dart" + }, + { + "path": "test/domain/entities/backup_processing_test.dart", + "sha256": "5280e64082162b20d24fbe0f6d3af8d4dc58beea49bff227c18e60b9681e1f81", + "member": "test/domain/entities/backup_processing_test.dart" + }, + { + "path": "test/domain/entities/backup_receipt_test.dart", + "sha256": "408872baf5e3c2e8ac777d682d21a9c16165b4b469f7372c49b75e6a19447190", + "member": "test/domain/entities/backup_receipt_test.dart" + }, + { + "path": "test/domain/entities/civil_date_time_contract_test.dart", + "sha256": "661dbe7cac24b09b9bd30d191cdf585b148eafff4f7364a46d55ff74bc8721bf", + "member": "test/domain/entities/civil_date_time_contract_test.dart" + }, + { + "path": "test/domain/entities/domain_entity_boundary_test.dart", + "sha256": "339de8a5b78ab55c2450271892f5d72531852d71996868c9e9a9a28f37ac1985", + "member": "test/domain/entities/domain_entity_boundary_test.dart" + }, + { + "path": "test/domain/entities/preparation_snapshot_validation_test.dart", + "sha256": "885567a042a9a93ec1b5ca6a1791ef3ccc0b81f7b72824d642ae4934cd2df3c5", + "member": "test/domain/entities/preparation_snapshot_validation_test.dart" + }, + { + "path": "test/domain/entities/preparation_timing_entity_test.dart", + "sha256": "1cb32752679f26390dd93f51921c3e172ab0196b41faa14273ed7c368c3500c5", + "member": "test/domain/entities/preparation_timing_entity_test.dart" + }, + { + "path": "test/domain/entities/resolved_schedule_runtime_test.dart", + "sha256": "500e848d212878ca249bf0d1df036fc77e2dd002cba100b1dadb4faf3a0dac81", + "member": "test/domain/entities/resolved_schedule_runtime_test.dart" + }, + { + "path": "test/domain/entities/schedule_notification_status_test.dart", + "sha256": "c47ed772514315e6fc02fb967fa586ea50718007743f336ab1c91de6fce8733b", + "member": "test/domain/entities/schedule_notification_status_test.dart" + }, + { + "path": "test/domain/entities/user_entity_test.dart", + "sha256": "b46a07ee0ead3ee9dff66d61327d1f07d9fe8cd3a6ff31f653712813fd09c884", + "member": "test/domain/entities/user_entity_test.dart" + }, + { + "path": "test/domain/recurrence/recurrence_engine_test.dart", + "sha256": "12d6971f5038aacbc62b8814c7e3a728a71008990a14feb08f9a9f3e0bd9f314", + "member": "test/domain/recurrence/recurrence_engine_test.dart" + }, + { + "path": "test/domain/use-cases/adjacent_schedule_instant_integration_test.dart", + "sha256": "f16d025243f2b7f87ac0fd3d2a46fd0ca237151b6c16faf0eb2bad1e79f5521a", + "member": "test/domain/use-cases/adjacent_schedule_instant_integration_test.dart" + }, + { + "path": "test/domain/use-cases/alarm_reconciliation_concurrency_test.dart", + "sha256": "621bfd5c1a14a48c5b39d97ebc37d16aa047ae7ed4be96fecc3b6fc96373b3e0", + "member": "test/domain/use-cases/alarm_reconciliation_concurrency_test.dart" + }, + { + "path": "test/domain/use-cases/cancel_alarms_use_cases_test.dart", + "sha256": "4427e8e23f559ef727171345128ba1599b8bc55bf8883eedb0abbaad7316231d", + "member": "test/domain/use-cases/cancel_alarms_use_cases_test.dart" + }, + { + "path": "test/domain/use-cases/create_schedule_form_submission_use_case_test.dart", + "sha256": "317a45914cb0490bb54fe25cfbdbb213ff0a95697820834774dd2936f88d6e67", + "member": "test/domain/use-cases/create_schedule_form_submission_use_case_test.dart" + }, + { + "path": "test/domain/use-cases/delivery_reconciliation_test.dart", + "sha256": "f9d324dd9dacb1dc195239c038f13d00c59d91c5fca61f58088e835fe7531fb3", + "member": "test/domain/use-cases/delivery_reconciliation_test.dart" + }, + { + "path": "test/domain/use-cases/detailed_notification_content_guard_test.dart", + "sha256": "aa2522444cafaf3833caff22526edb4721266dc22295be6a6eb2a2528d0e1d2e", + "member": "test/domain/use-cases/detailed_notification_content_guard_test.dart" + }, + { + "path": "test/domain/use-cases/detailed_notification_delivery_acceptance_test.dart", + "sha256": "f004522def98c2f8ddafa8e5d44e71f930c6433a7ac874088cef26066bf1862d", + "member": "test/domain/use-cases/detailed_notification_delivery_acceptance_test.dart" + }, + { + "path": "test/domain/use-cases/get_nearest_upcoming_schedule_use_case_test.dart", + "sha256": "54263123ec7ca7c393a66b9d535fd0e03cc88975820f63686a6ef86709fa9d54", + "member": "test/domain/use-cases/get_nearest_upcoming_schedule_use_case_test.dart" + }, + { + "path": "test/domain/use-cases/load_schedule_form_draft_use_case_test.dart", + "sha256": "f26a043fa3b91237f23df5b8614bfdd0a0dbeae9b5a59c4744704287e46cc280", + "member": "test/domain/use-cases/load_schedule_form_draft_use_case_test.dart" + }, + { + "path": "test/domain/use-cases/local_data_workflows_test.dart", + "sha256": "19a320f4c6f7517e9cd14d7133ad71c5e6b96b53358a57357765690aed914d50", + "member": "test/domain/use-cases/local_data_workflows_test.dart" + }, + { + "path": "test/domain/use-cases/nearest_schedule_clock_test.dart", + "sha256": "68b6a322ba2b61ffb1c8124703955bff28877a2432cebeafabf6f715ad7c3865", + "member": "test/domain/use-cases/nearest_schedule_clock_test.dart" + }, + { + "path": "test/domain/use-cases/reconcile_alarms_use_case_test.dart", + "sha256": "37256b5e5a466542bf63b870ec7c0bfbb10389e208ebb416765ec41d7ea1a6b9", + "member": "test/domain/use-cases/reconcile_alarms_use_case_test.dart" + }, + { + "path": "test/domain/use-cases/recurring_alarm_integration_test.dart", + "sha256": "0bcb7adf7cb7c0231a41c40e3d01a1edcafd28fe4242f2eddfd82a707564372f", + "member": "test/domain/use-cases/recurring_alarm_integration_test.dart" + }, + { + "path": "test/domain/use-cases/schedule_date_use_cases_test.dart", + "sha256": "d7487fffbbc39e1b1201884fcb5b2f132af7346f7596fe27180bee9b2f47abf3", + "member": "test/domain/use-cases/schedule_date_use_cases_test.dart" + }, + { + "path": "test/domain/use-cases/schedule_deletion_use_case_test.dart", + "sha256": "f2606a79403d99c54b613149357858e807b57f77d5109d396ab1517b775871c4", + "member": "test/domain/use-cases/schedule_deletion_use_case_test.dart" + }, + { + "path": "test/domain/use-cases/schedule_mutation_alarm_effects_coordinator_test.dart", + "sha256": "b767e76b1bd30c61b370de46f1f4b791224317ca270c2c1fd1580a0ca4af8253", + "member": "test/domain/use-cases/schedule_mutation_alarm_effects_coordinator_test.dart" + }, + { + "path": "test/domain/use-cases/schedule_mutation_use_cases_test.dart", + "sha256": "60225de3b22ab760a61cf0b6d6e084fb4376a92f1bab9e0b33908888dfa723b0", + "member": "test/domain/use-cases/schedule_mutation_use_cases_test.dart" + }, + { + "path": "test/domain/use-cases/schedule_preparation_session_use_case_test.dart", + "sha256": "57d22417fc10181e396152e28daf7554a11a047dc33be16c5d2f0547be966bad", + "member": "test/domain/use-cases/schedule_preparation_session_use_case_test.dart" + }, + { + "path": "test/domain/use-cases/timed_preparation_use_cases_test.dart", + "sha256": "c76c5c133e7f152a93592ffad8c63919803ddaacdb3da9e9d108f9883ddbc60e", + "member": "test/domain/use-cases/timed_preparation_use_cases_test.dart" + }, + { + "path": "test/domain/use-cases/update_schedule_form_submission_use_case_test.dart", + "sha256": "5fd739f2cbaada8a787d14d1af8dd7b9718df86027d356919ed7669b5f78b6a9", + "member": "test/domain/use-cases/update_schedule_form_submission_use_case_test.dart" + }, + { + "path": "test/fixtures/database/README.md", + "sha256": "bc45e4b544a7718195b48d108aac5ae345a0985635b391aef4af09f39b0bcdb1", + "member": "test/fixtures/database/README.md" + }, + { + "path": "test/fixtures/database/populated_local.sql", + "sha256": "27d9b1015e956f2ae33fdded79d585c5b86477f203192db9166640af0171ef13", + "member": "test/fixtures/database/populated_local.sql" + }, + { + "path": "test/fixtures/database/populated_recurring.sql", + "sha256": "44cf1a03ad30d719d6853f50f8fc8e52b673ff141303a436d1a49cf4e9fb93d9", + "member": "test/fixtures/database/populated_recurring.sql" + }, + { + "path": "test/fixtures/database/schema_v1.sql", + "sha256": "782cd3a3cfdca56c67f896a7fbdfa9791a349f77b2b65e21c7f0c0f2ce4a121d", + "member": "test/fixtures/database/schema_v1.sql" + }, + { + "path": "test/fixtures/database/schema_v2.sql", + "sha256": "a7ec3c57f1c86a58ef45c3417abe187fa4e44a0be87e3861a7182cb3ab02d7c5", + "member": "test/fixtures/database/schema_v2.sql" + }, + { + "path": "test/fixtures/database/schema_v3.sql", + "sha256": "4c2d7b8be37fd22ff614bc1844872f23147d8e8623445652e2f949513583b55c", + "member": "test/fixtures/database/schema_v3.sql" + }, + { + "path": "test/fixtures/database/schema_v4.sql", + "sha256": "60a5c9fef7385280e55adc622cf9f33f507de180a4c35d4c3d09285fcfd9d0ea", + "member": "test/fixtures/database/schema_v4.sql" + }, + { + "path": "test/helpers/c08_home_query_fixture.dart", + "sha256": "ad45d8ceb2319781b42df7092480d962e90d640c371d86332d5f60af09aa4db6", + "member": "test/helpers/c08_home_query_fixture.dart" + }, + { + "path": "test/helpers/fake_clock.dart", + "sha256": "3afc79b691795fcd1505b316b14c9784cf08746ff9ed0084498334c634408951", + "member": "test/helpers/fake_clock.dart" + }, + { + "path": "test/helpers/isolated_alarm_owner.dart", + "sha256": "84a10eff3d2f4bbfca21e96c8d3e3d40fa47b11feecf0ce885df1c584ee22352", + "member": "test/helpers/isolated_alarm_owner.dart" + }, + { + "path": "test/helpers/nearest_query_fixture.dart", + "sha256": "dd4606373f16a9e47d35cd392a8fc7d55a7305aa5cc25cbde8426fabd5520623", + "member": "test/helpers/nearest_query_fixture.dart" + }, + { + "path": "test/helpers/noop_alarm_cleanup.dart", + "sha256": "5b26450418c9b461dbf338d4a794a1535d7f4b711d7f14dab261bc67bab00c7a", + "member": "test/helpers/noop_alarm_cleanup.dart" + }, + { + "path": "test/helpers/noop_alarm_reconciliation.dart", + "sha256": "dd2a9e0f9888a7c64466daca2b16e78bac3a6a2361a997d69ca7ad6355548fd9", + "member": "test/helpers/noop_alarm_reconciliation.dart" + }, + { + "path": "test/helpers/refresh_capture.dart", + "sha256": "2d65d76d15c359ee2625bafc1b1c18cedaf7ab1827ed9d6190b12b861aac0dbd", + "member": "test/helpers/refresh_capture.dart" + }, + { + "path": "test/helpers/restore_staging_fixture.dart", + "sha256": "69f7e7852388d5ba2c02bd45c78cfd50e28fc5007cd74c9afe5c14858a51bdd4", + "member": "test/helpers/restore_staging_fixture.dart" + }, + { + "path": "test/helpers/schedule_deletion_fixture.dart", + "sha256": "de58728588e10c253f04d54a7d66b65caa549bda7a2984423baeb8b342c3e52e", + "member": "test/helpers/schedule_deletion_fixture.dart" + }, + { + "path": "test/helpers/schedule_deletion_workflow_fixture.dart", + "sha256": "c60d7b0612694ac7f98be1c4852452aa45ad1f65b00fea714cc5718ac526550e", + "member": "test/helpers/schedule_deletion_workflow_fixture.dart" + }, + { + "path": "test/helpers/sodium_test_loader.dart", + "sha256": "be13982db4d2b5142b809f0ffd5872445a20a2db48c335751ef83f3c2344139d", + "member": "test/helpers/sodium_test_loader.dart" + }, + { + "path": "test/helpers/u02_save_fixture.dart", + "sha256": "f6d2bae519a97732f4a56d0db0401e1bad2f75f5cebc8e38a64babb6934a661d", + "member": "test/helpers/u02_save_fixture.dart" + }, + { + "path": "test/local_only_boundary_test.dart", + "sha256": "ac033712134bcef712914e03420ca90f8f362c01178491e4ad86fac6a8932646", + "member": "test/local_only_boundary_test.dart" + }, + { + "path": "test/native/AlarmLaunchPayloadTest.kt", + "sha256": "e980cdbd69408f4cd9cadebbe37e92054d9cc0c6c72f0278a03d220869558fd2", + "member": "test/native/AlarmLaunchPayloadTest.kt" + }, + { + "path": "test/native/AlarmLaunchPayloadTest.swift", + "sha256": "88b1d912fab5be63d4eda131d6f1bdd6c5a3916d159d76dedf418b969221f451", + "member": "test/native/AlarmLaunchPayloadTest.swift" + }, + { + "path": "test/presentation/alarm/components/preparation_step_list_widget_test.dart", + "sha256": "c48c593db3e0d83db5a5458151e898ed7857da71ff7217d3a7576b45bbb9a143", + "member": "test/presentation/alarm/components/preparation_step_list_widget_test.dart" + }, + { + "path": "test/presentation/alarm/screens/preparation_flow_widget_test.dart", + "sha256": "952bd430589b6aec3aa571ac2f8301421700923c7a3171aa68f36e33fb0552fe", + "member": "test/presentation/alarm/screens/preparation_flow_widget_test.dart" + }, + { + "path": "test/presentation/alarm/screens/schedule_start_commit_widget_test.dart", + "sha256": "f8e872f748bb4eb14a96a545240fdc072ecbe9c9fc3b83b8a8c63dbf524bbdc9", + "member": "test/presentation/alarm/screens/schedule_start_commit_widget_test.dart" + }, + { + "path": "test/presentation/alarm/utils/preparation_step_state_mapper_test.dart", + "sha256": "d7cbf6d14e0b22352e025385fbfebf307ed3f496fab86ed58b2394ebd64748f5", + "member": "test/presentation/alarm/utils/preparation_step_state_mapper_test.dart" + }, + { + "path": "test/presentation/alarm_allow/alarm_allow_screen_test.dart", + "sha256": "dfd31f5fdc9fd2d295a6b9a015867ed1444cbf44b724dd004de7777e56696810", + "member": "test/presentation/alarm_allow/alarm_allow_screen_test.dart" + }, + { + "path": "test/presentation/app/bloc/auth/auth_bloc_test.dart", + "sha256": "b95d1caaff25751c97a24af5b920b21de157628db2656e8b52cf69289083e2d0", + "member": "test/presentation/app/bloc/auth/auth_bloc_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/nearest_active_run_lifecycle_test.dart", + "sha256": "c260ad845d1a13adeaa2726ad6ab878b8a93640d9006398a8963dd9a18b5e685", + "member": "test/presentation/app/bloc/schedule/nearest_active_run_lifecycle_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/nearest_boundary_intent_test.dart", + "sha256": "6a309a7d1774caa30926369f04ed1e1dba66a8686c627116568ca27479c611fb", + "member": "test/presentation/app/bloc/schedule/nearest_boundary_intent_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/nearest_cancel_delivery_race_test.dart", + "sha256": "1c12e4fa9f59c84bc8b73fe7057e28a05930acea4a351bf3c4a0eb88a560d835", + "member": "test/presentation/app/bloc/schedule/nearest_cancel_delivery_race_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/nearest_committed_start_projection_test.dart", + "sha256": "e406fdb8075cc75549b8d30a0c7b5a9bea70f612915d5d62e234249713348888", + "member": "test/presentation/app/bloc/schedule/nearest_committed_start_projection_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/nearest_query_lifecycle_test.dart", + "sha256": "21fffef598df1e527362189640c971dd47cad1d332e2581be28d34c58c7c2664", + "member": "test/presentation/app/bloc/schedule/nearest_query_lifecycle_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/notification_prompt_race_test.dart", + "sha256": "7a9f3a0a285b28ec2ca4239d88b67dfc783a6b650e29bb5343a89776c54d2a36", + "member": "test/presentation/app/bloc/schedule/notification_prompt_race_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/preparation_start_recovery_test.dart", + "sha256": "1af89ef608c92f1015996fee68e9e7e82b7615fc75d3fd70dee9ed52c307416d", + "member": "test/presentation/app/bloc/schedule/preparation_start_recovery_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/preparation_step_delivery_test.dart", + "sha256": "5a180daf80c10f7e07438bc4a0e03f519cc111349b42a02b8478a2c562df8b1f", + "member": "test/presentation/app/bloc/schedule/preparation_step_delivery_test.dart" + }, + { + "path": "test/presentation/app/bloc/schedule/schedule_bloc_test.dart", + "sha256": "9533818419674491d5173cc7d30c5a78f5d45798f00e2863468dca5e1134210e", + "member": "test/presentation/app/bloc/schedule/schedule_bloc_test.dart" + }, + { + "path": "test/presentation/app/cubit/alarm_gate_cubit_test.dart", + "sha256": "ceaa6032a46b88460ce304c14847904d529e668080de22805535b58169901e53", + "member": "test/presentation/app/cubit/alarm_gate_cubit_test.dart" + }, + { + "path": "test/presentation/app/cubit/notification_gate_cubit_test.dart", + "sha256": "14a3b1264b1c4c4e0569f9b64667137b7af99d82ff9fee94bc78c63ba4b5fc3b", + "member": "test/presentation/app/cubit/notification_gate_cubit_test.dart" + }, + { + "path": "test/presentation/backup/backup_processing_panel_test.dart", + "sha256": "699551f63bc1703f7384f1d9e94e9e230cf6ac3b8edf8b3276848b0c080f8de7", + "member": "test/presentation/backup/backup_processing_panel_test.dart" + }, + { + "path": "test/presentation/backup/backup_recurring_time_review_screen_test.dart", + "sha256": "8b34cc1a36c3381478cf6e045c44073cb24f87f469fe52bcbd520f001e0c24db", + "member": "test/presentation/backup/backup_recurring_time_review_screen_test.dart" + }, + { + "path": "test/presentation/backup/backup_time_review_screen_test.dart", + "sha256": "af7c584c7f72196ef2222550e4a0539490cdd7f08a8c48f7db6365313483fa1d", + "member": "test/presentation/backup/backup_time_review_screen_test.dart" + }, + { + "path": "test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart", + "sha256": "7e60ee8a9db4a57767e21b24d53d56316ee0b22a3358d77604f250c59aba9291", + "member": "test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart" + }, + { + "path": "test/presentation/calendar/component/schedule_deletion_dialog_test.dart", + "sha256": "f1ad0aa4f0c7e2d93ea5030297cb5f046c14512ed9626ccf4e28ab87718af3c7", + "member": "test/presentation/calendar/component/schedule_deletion_dialog_test.dart" + }, + { + "path": "test/presentation/calendar/component/schedule_detail_test.dart", + "sha256": "25557edccc2834583209d5c680102adc8b5a3a57c851eca2ed43add1ae272f91", + "member": "test/presentation/calendar/component/schedule_detail_test.dart" + }, + { + "path": "test/presentation/calendar/screens/calendar_screen_test.dart", + "sha256": "a45fde08fc3c2c481ebae2251fb9a643b92c42589dcaaf6898ec7c2c08d7f507", + "member": "test/presentation/calendar/screens/calendar_screen_test.dart" + }, + { + "path": "test/presentation/early_late/bloc/early_late_screen_bloc_test.dart", + "sha256": "0988303a4941fe4b27922b579d3c86c2c0173d8469c5e1e81aa55e8621c6d26c", + "member": "test/presentation/early_late/bloc/early_late_screen_bloc_test.dart" + }, + { + "path": "test/presentation/event_value_objects_test.dart", + "sha256": "0f7b449b0e9351105421affbaa13c8e93965a8c4b6f1b943127f60e409d90c3f", + "member": "test/presentation/event_value_objects_test.dart" + }, + { + "path": "test/presentation/home/bloc/device_today_schedule_test.dart", + "sha256": "4a8a16806a1fbb1968003adcd04d84205f696cf8332d593a4f670cc54fe96fe3", + "member": "test/presentation/home/bloc/device_today_schedule_test.dart" + }, + { + "path": "test/presentation/home/bloc/schedule_timer_bloc_test.dart", + "sha256": "73c2b9a028902e9888e27b05aa63bceb287d7e63698f5ef6cd8e1b8c03511499", + "member": "test/presentation/home/bloc/schedule_timer_bloc_test.dart" + }, + { + "path": "test/presentation/home/bloc/weekly_schedules_bloc_test.dart", + "sha256": "f522e2e344fc0fcc78db1041a1886ca7ca7a379385d1f3873c1b003b086375ed", + "member": "test/presentation/home/bloc/weekly_schedules_bloc_test.dart" + }, + { + "path": "test/presentation/home/components/month_calendar_test.dart", + "sha256": "738b39300b3913e865ec68741906e66738cfad4f373b689555c6fb87812c1e07", + "member": "test/presentation/home/components/month_calendar_test.dart" + }, + { + "path": "test/presentation/home/components/week_calendar_test.dart", + "sha256": "f4521de4da825b0066e217d0ddf63cf43213ee2e914a47bf6023e5fb250525dd", + "member": "test/presentation/home/components/week_calendar_test.dart" + }, + { + "path": "test/presentation/home/home_device_day_test.dart", + "sha256": "acdac6b64b7a754d752ade69187e731b62e9179c60e4618ef404276aaf2de7e3", + "member": "test/presentation/home/home_device_day_test.dart" + }, + { + "path": "test/presentation/home/nearest_query_home_test.dart", + "sha256": "e09fa8c4caa2a1d2e8589697cf001830bdaa905ab21aac7b63f34113453623f7", + "member": "test/presentation/home/nearest_query_home_test.dart" + }, + { + "path": "test/presentation/home/screens/home_screen_tmp_test.dart", + "sha256": "eff156891525808637a4218fd31da9e1b021dcb51bbe128e9104cb4bcb9117a3", + "member": "test/presentation/home/screens/home_screen_tmp_test.dart" + }, + { + "path": "test/presentation/home/utils/today_tile_navigation_test.dart", + "sha256": "36b0e271e66801188d2e58fe067370ea7187bcbfceb51f83fac48d00af332a80", + "member": "test/presentation/home/utils/today_tile_navigation_test.dart" + }, + { + "path": "test/presentation/my_page/detailed_notification_settings_cubit_test.dart", + "sha256": "259496417c40dc2b536a72f739de71de514eac659f2bb79eb532785b3bc33ebd", + "member": "test/presentation/my_page/detailed_notification_settings_cubit_test.dart" + }, + { + "path": "test/presentation/my_page/my_data_export_test.dart", + "sha256": "3f4c2a8090db0d8a4ef96b565a127ec8ca627cf02a1b24584412f3416cb27bb8", + "member": "test/presentation/my_page/my_data_export_test.dart" + }, + { + "path": "test/presentation/my_page/my_data_recurring_time_review_test.dart", + "sha256": "379fbcd0cd5adc9c68f78aa519abd9c3eab3024195dccdac6728972ec2efebb6", + "member": "test/presentation/my_page/my_data_recurring_time_review_test.dart" + }, + { + "path": "test/presentation/my_page/my_data_restore_test.dart", + "sha256": "7ac6b68bb2f972f03dd5ae238c3e101ee3217e6cd8e7f2fb55612f9a21ef31c3", + "member": "test/presentation/my_page/my_data_restore_test.dart" + }, + { + "path": "test/presentation/my_page/my_data_time_review_test.dart", + "sha256": "aabb0c637836d97d93fc70350ea62bafc33e19cc4f0d1faa29e0afe01ea9466d", + "member": "test/presentation/my_page/my_data_time_review_test.dart" + }, + { + "path": "test/presentation/my_page/my_data_unknown_restore_test.dart", + "sha256": "b3a8a0fa15df347b88475d53a21f98406b5ea7c514068c36c490ab444cd0ed74", + "member": "test/presentation/my_page/my_data_unknown_restore_test.dart" + }, + { + "path": "test/presentation/my_page/my_page_screen_test.dart", + "sha256": "7faecf15af06445c4297dbb2da68798486c646f3e2409d3596443b5ab16955a7", + "member": "test/presentation/my_page/my_page_screen_test.dart" + }, + { + "path": "test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart", + "sha256": "7dc908fdb6b0bc798fb54745807307ac914e3ed649c639b6c85d429608963388", + "member": "test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart" + }, + { + "path": "test/presentation/notification_allow/notification_allow_screen_test.dart", + "sha256": "5c49a040772d717e262615633d4a3be1e41cf7fc0badb83d528900ab5029f3c5", + "member": "test/presentation/notification_allow/notification_allow_screen_test.dart" + }, + { + "path": "test/presentation/onboarding/cubit/onboarding_cubit_test.dart", + "sha256": "d12c9c3cddefc66a3215bcb5d294894fc70c0b78a6f1edfde965e54522ef1e56", + "member": "test/presentation/onboarding/cubit/onboarding_cubit_test.dart" + }, + { + "path": "test/presentation/onboarding/screens/onboarding_screen_test.dart", + "sha256": "d35a018b847eb73090eab6b02f049c157f385db2f7b820a48bbe0eeae516fcbc", + "member": "test/presentation/onboarding/screens/onboarding_screen_test.dart" + }, + { + "path": "test/presentation/recurring/recurrence_settings_sheet_test.dart", + "sha256": "fc4af53c9863dd93948e40f47977db067c86d6639e5d9a4cb51539fcabbec2b2", + "member": "test/presentation/recurring/recurrence_settings_sheet_test.dart" + }, + { + "path": "test/presentation/recurring/recurrence_sheet_title_accessibility_test.dart", + "sha256": "866a35393ecc5ac93579414e1e5299639cd3d0f66bffc8026ca7e05921a09a2d", + "member": "test/presentation/recurring/recurrence_sheet_title_accessibility_test.dart" + }, + { + "path": "test/presentation/recurring/recurring_form_integration_test.dart", + "sha256": "cdf5714fd9eb3d2b762abbd3aecd68391e726b9d7aaf021273578bc65ccd8577", + "member": "test/presentation/recurring/recurring_form_integration_test.dart" + }, + { + "path": "test/presentation/recurring/recurring_screens_test.dart", + "sha256": "eb4ce76ab2617cc8a2076fb4016bcb60bffb343ad276386d88f1d65a052c7480", + "member": "test/presentation/recurring/recurring_screens_test.dart" + }, + { + "path": "test/presentation/recurring/recurring_time_correction_plan_summary_test.dart", + "sha256": "e8247dd976d01d5aa95a24bcc2743ed1549b0690191c28db82c91dd6dcfac519", + "member": "test/presentation/recurring/recurring_time_correction_plan_summary_test.dart" + }, + { + "path": "test/presentation/recurring/recurring_time_correction_screen_test.dart", + "sha256": "bf9b91ad6c52475eed1d944edd7703b0d161986a2a99e3315cfe5ec807e70230", + "member": "test/presentation/recurring/recurring_time_correction_screen_test.dart" + }, + { + "path": "test/presentation/recurring/refreshed_screens_test.dart", + "sha256": "8e5c92be32a788098719fee730571f5f5ea9b63378c89f020ac6ac1b770a0995", + "member": "test/presentation/recurring/refreshed_screens_test.dart" + }, + { + "path": "test/presentation/recurring/u02_recurrence_acceptance_integration_test.dart", + "sha256": "61e6327050224212455b1ae1283e00fc0abdf979fe07647d69bcccb649cf3e45", + "member": "test/presentation/recurring/u02_recurrence_acceptance_integration_test.dart" + }, + { + "path": "test/presentation/schedule_create/bloc/following_zone_draft_integration_test.dart", + "sha256": "58d1b9022f048ce7d9494962cc365640e14f081670422ec02ad9b1de7453b876", + "member": "test/presentation/schedule_create/bloc/following_zone_draft_integration_test.dart" + }, + { + "path": "test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart", + "sha256": "61131e5078da9a3f67b3eedb114d43115f3e01c8acce7e1bbb8de584cb4b39be", + "member": "test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart" + }, + { + "path": "test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart", + "sha256": "c2eda1d54ff8c6cd42d7cb1aa65b70a1056711f19c3bc0c871c9da4084c36267", + "member": "test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart" + }, + { + "path": "test/presentation/schedule_create/bloc/schedule_time_review_sqlite_integration_test.dart", + "sha256": "2a94ae892b93bce382191146a52622f3cf36672c0b7742c46a88a69d4f40e031", + "member": "test/presentation/schedule_create/bloc/schedule_time_review_sqlite_integration_test.dart" + }, + { + "path": "test/presentation/schedule_create/components/keyboard_backed_bottom_sheet_test.dart", + "sha256": "b911cd03cb272ab31e151e9358a45622ec376125e8491c9784f8877f76a30108", + "member": "test/presentation/schedule_create/components/keyboard_backed_bottom_sheet_test.dart" + }, + { + "path": "test/presentation/schedule_create/components/schedule_multi_page_form_test.dart", + "sha256": "d33af602ad57bad84609aee1c5c901934bf39a59124da4db33a2d9a735d2f8a6", + "member": "test/presentation/schedule_create/components/schedule_multi_page_form_test.dart" + }, + { + "path": "test/presentation/schedule_create/input_models/backend_validation_input_models_test.dart", + "sha256": "78239f8b01b32aaf04eef14de904b32dd435d2d9e0cd40733e20cce7ecf5300b", + "member": "test/presentation/schedule_create/input_models/backend_validation_input_models_test.dart" + }, + { + "path": "test/presentation/schedule_create/preparation_form/preparation_form_create_list_test.dart", + "sha256": "e172683255e169cbb427ba15b5bd667f46630675804d9459650526d9a581dd1f", + "member": "test/presentation/schedule_create/preparation_form/preparation_form_create_list_test.dart" + }, + { + "path": "test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart", + "sha256": "f2a18aadc5d7fb9bc34750f55f4e8c13e7d97b9e31cac62f27174b4e467d62d6", + "member": "test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart" + }, + { + "path": "test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart", + "sha256": "d2abdd2f9ff7b959be18057ed95e6ec881f2d89eaba1d8a0b6827e47e694f276", + "member": "test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart" + }, + { + "path": "test/presentation/schedule_create/preparation_form/preparation_form_state_event_test.dart", + "sha256": "d4a3eca6ad1ae543618c31ec08347aea66f2a53ea0212c1299bd20687f56b167", + "member": "test/presentation/schedule_create/preparation_form/preparation_form_state_event_test.dart" + }, + { + "path": "test/presentation/schedule_create/preparation_form/preparation_step_form_cubit_test.dart", + "sha256": "fbe84161f025163745d3cc4a36d73c5eab395629ab21ed60d149ac231bd847c2", + "member": "test/presentation/schedule_create/preparation_form/preparation_step_form_cubit_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_date_time/input_models/schedule_date_input_model_test.dart", + "sha256": "25030686e6f9a93adfafd7c3e8436108ad34600ba8049e982883c1aafa86b8bd", + "member": "test/presentation/schedule_create/schedule_date_time/input_models/schedule_date_input_model_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_date_time/input_models/schedule_time_input_model_test.dart", + "sha256": "730d0f24f96a04ce329402da4ba27107d028232966717fad59f0b8cf7c75c7b6", + "member": "test/presentation/schedule_create/schedule_date_time/input_models/schedule_time_input_model_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart", + "sha256": "9db8535477ea7e4ffd402ab6998e551d3d87a9210b61d62bfceb5c25bfb9d3a5", + "member": "test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart", + "sha256": "020516af4dda0745b89b1136aee5b271e8d6484fb4a0cccaa62d6577e96bcfcb", + "member": "test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_date_time/schedule_date_time_state_test.dart", + "sha256": "917f3e6c2d7a85ad59172a9ce0a4647ad55a3b2d204b82d68a8435dc299109e4", + "member": "test/presentation/schedule_create/schedule_date_time/schedule_date_time_state_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_date_time/schedule_time_zone_picker_contract_test.dart", + "sha256": "2c43277f5691027f1e0f7cc6d6de9b38e53ee3f17abf8338c557807737f0403c", + "member": "test/presentation/schedule_create/schedule_date_time/schedule_time_zone_picker_contract_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_place_moving_time/schedule_place_moving_time_form_test.dart", + "sha256": "26f5dea878c44875d3cf6392ffd44fe3928d4f75266e442b22aa8363485992d2", + "member": "test/presentation/schedule_create/schedule_place_moving_time/schedule_place_moving_time_form_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state_test.dart", + "sha256": "867e648c05854e27e5abdd2fcb314545f9ea5b4e46cc2dff2d47d789b343f1e1", + "member": "test/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart", + "sha256": "f2280df0f9bad6aeb0a0a6f27e00917c9a2a50e2c6328f77a97c4f1dfc96e121", + "member": "test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart", + "sha256": "03cb5f04f6d29ada101beeb53fadb3341c2effa40b9a128ae956e0e143334df9", + "member": "test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_time_inputs_cubit_test.dart", + "sha256": "d63de9fe41f195450cbc080864d7301fe43a9a399bbb93741098048a5e1e1303", + "member": "test/presentation/schedule_create/schedule_time_inputs_cubit_test.dart" + }, + { + "path": "test/presentation/schedule_create/schedule_time_save_review_dialog_test.dart", + "sha256": "d6f919a2f1f9908a69bfcaf5c5245dd5d01ecb1e792364fe8e4228c4492d4c93", + "member": "test/presentation/schedule_create/schedule_time_save_review_dialog_test.dart" + }, + { + "path": "test/presentation/schedule_create/time_correction/schedule_time_correction_screen_test.dart", + "sha256": "51c30bb7c9cb3c721695e47d3aa6ffebeb5b2ef4016668784010c91347af6fea", + "member": "test/presentation/schedule_create/time_correction/schedule_time_correction_screen_test.dart" + }, + { + "path": "test/presentation/schedule_create/u02_form_acceptance_integration_test.dart", + "sha256": "5344624ed4d0d18b725c800b4c8ff77f8690d858ba2ec8b41f60404024bca997", + "member": "test/presentation/schedule_create/u02_form_acceptance_integration_test.dart" + }, + { + "path": "test/presentation/schedule_create/u02_form_authority_integration_test.dart", + "sha256": "c9f298e2ffbdda661ac38bb655eb04c3f4181ffe4e1f3b998015d4a41e1fabf0", + "member": "test/presentation/schedule_create/u02_form_authority_integration_test.dart" + }, + { + "path": "test/presentation/shared/arc_indicator_test.dart", + "sha256": "5188c06cc45a9a8eb100567b3c9e2cef3f8e615aeedcc1279dfdd283fedb7ac9", + "member": "test/presentation/shared/arc_indicator_test.dart" + }, + { + "path": "test/presentation/shared/components/calendar/schedule_marker_builder_test.dart", + "sha256": "70ec3d801ba70ace6d9ccd3d7cb0326e694096b64db0cbbcb01d6c9f8c4da9f6", + "member": "test/presentation/shared/components/calendar/schedule_marker_builder_test.dart" + }, + { + "path": "test/presentation/shared/components/civil_date_time_picker_test.dart", + "sha256": "34345599bf2e8bc1073ef3179890431d65073c75e6e1a0e9c5d48f825db262b2", + "member": "test/presentation/shared/components/civil_date_time_picker_test.dart" + }, + { + "path": "test/presentation/shared/components/cupertino_picker_modal_test.dart", + "sha256": "8b813a5aca41660f2aef4eab2ffbda0cce9af44883eade6100e2dd7f6e731a47", + "member": "test/presentation/shared/components/cupertino_picker_modal_test.dart" + }, + { + "path": "test/presentation/shared/components/custom_alert_dialog_test.dart", + "sha256": "77a4dc5c5996fd715c1c892007bbf1c40ebb4543735c072bd81a32582cf4802b", + "member": "test/presentation/shared/components/custom_alert_dialog_test.dart" + }, + { + "path": "test/presentation/shared/components/modal_wide_button_test.dart", + "sha256": "49e7355c7ee17c7c6ea9558a4f7dbde2b9f7614a0cfb2c56009badb142c64214", + "member": "test/presentation/shared/components/modal_wide_button_test.dart" + }, + { + "path": "test/presentation/shared/components/two_button_delete_dialog_test.dart", + "sha256": "b071ed380e919f68a044da615daddd8ed77ece897dd9c6e934f7e1e26f44f206", + "member": "test/presentation/shared/components/two_button_delete_dialog_test.dart" + }, + { + "path": "test/presentation/shared/constants/app_colors_test.dart", + "sha256": "d704a295c34a816744d08577a7ffb159c3528cb9c8371400ca40af32ad27171e", + "member": "test/presentation/shared/constants/app_colors_test.dart" + }, + { + "path": "test/presentation/shared/constants/early_late_text_images_test.dart", + "sha256": "0b41247d2ea793b50a9d2e9accbc27dc451412b5f5b7f587303b7d3d6b9a7b2e", + "member": "test/presentation/shared/constants/early_late_text_images_test.dart" + }, + { + "path": "test/presentation/shared/router/app_route_transition_test.dart", + "sha256": "81ff608cbaf8d1090c8428c83d1086d57da27475b078cbbc00fc9b37efe9aaf8", + "member": "test/presentation/shared/router/app_route_transition_test.dart" + }, + { + "path": "test/presentation/shared/router/go_router_redirect_test.dart", + "sha256": "cc40181de0cae9e94c13b451377d055f965af2a2a800ed3a22bc2776e54bfe97", + "member": "test/presentation/shared/router/go_router_redirect_test.dart" + }, + { + "path": "test/presentation/shared/router/notification_launch_widget_test.dart", + "sha256": "00be41421eb16f96f060869005a392e3a48214fec358558cec8cc3a9ee422799", + "member": "test/presentation/shared/router/notification_launch_widget_test.dart" + }, + { + "path": "test/presentation/shared/router/route_arguments_test.dart", + "sha256": "1050f98213384ef4d69346546f3782a12f176551856aac8aa5524d344e3bdfc8", + "member": "test/presentation/shared/router/route_arguments_test.dart" + }, + { + "path": "test/presentation/shared/theme/button_and_calendar_theme_test.dart", + "sha256": "545e448abde56eab8adc8473a46a1c43a22782a44f576a04c0aa40cf22b45b87", + "member": "test/presentation/shared/theme/button_and_calendar_theme_test.dart" + }, + { + "path": "test/presentation/shared/theme/tile_style_test.dart", + "sha256": "b7d75fbd06d31f8b399dd54af958a60f8af22fc71afa216dc4952c2d68aeaa00", + "member": "test/presentation/shared/theme/tile_style_test.dart" + }, + { + "path": "test/presentation/shared/time/device_time_zone_scope_test.dart", + "sha256": "c0b032e8c210c4db29bc8f29c554a6ebb851b7b1cd8d05a6588ff2fbb2c96a16", + "member": "test/presentation/shared/time/device_time_zone_scope_test.dart" + }, + { + "path": "test/presentation/shared/time/zoned_recurrence_review_contract_test.dart", + "sha256": "1f71e4c2f739a03b4f7c1f2ec39623441ccc17f7a99c925b364a281ded4ad7c2", + "member": "test/presentation/shared/time/zoned_recurrence_review_contract_test.dart" + }, + { + "path": "test/presentation/shared/time/zoned_schedule_presentation_contract_test.dart", + "sha256": "48b0411b7ae765258fbfacb6be0353148754270ead61f1124b60dad4d964c6ab", + "member": "test/presentation/shared/time/zoned_schedule_presentation_contract_test.dart" + }, + { + "path": "test/presentation/shared/time/zoned_schedule_surface_contract_test.dart", + "sha256": "78d6fff21a0702892f52bbb46255cb4b9bba2766d18ec751a95602e6301fe09f", + "member": "test/presentation/shared/time/zoned_schedule_surface_contract_test.dart" + }, + { + "path": "test/presentation/shared/utils/time_format_test.dart", + "sha256": "b92dbbb3cead69fb424becf32ffe3bec0d6da2ee27152aea8e89082af039f31e", + "member": "test/presentation/shared/utils/time_format_test.dart" + }, + { + "path": "test/presentation/startup/damaged_store_restore_test.dart", + "sha256": "8ea3af8fac22e3f973deb2254b6658930427c34719faa328ff0c8761f5cd6964", + "member": "test/presentation/startup/damaged_store_restore_test.dart" + }, + { + "path": "test/presentation/startup/local_reset_recovery_test.dart", + "sha256": "f1afb3d065a4b4fbe8ee14aa66205cd8b74eea589da8fa011d00d3bbf6ec91f8", + "member": "test/presentation/startup/local_reset_recovery_test.dart" + }, + { + "path": "test/presentation/startup/local_startup_store_recovery_test.dart", + "sha256": "1787ba4e5f430b0f9d18f64bf95ef251b52d7a3b50cc761adf42d8ff3d453873", + "member": "test/presentation/startup/local_startup_store_recovery_test.dart" + }, + { + "path": "test/presentation/startup/migration_recovery_integration_test.dart", + "sha256": "536ac9b8cead2e59f2d0028a6f81198e375d2dc91fbde3e61d3867fe5eadeeb3", + "member": "test/presentation/startup/migration_recovery_integration_test.dart" + }, + { + "path": "test/presentation/startup/recovery_restore_screen_test.dart", + "sha256": "cab710a31af208992ae695cadfd2ff6c97abe1eda716e9b4c5ce9f3d7b2e7a5a", + "member": "test/presentation/startup/recovery_restore_screen_test.dart" + }, + { + "path": "test/presentation/startup/recovery_time_review_test.dart", + "sha256": "a7b2f1d32e34d4d9aafe0133954275782ff666108e7a9e0f02e4003e344d5b2e", + "member": "test/presentation/startup/recovery_time_review_test.dart" + }, + { + "path": "test/repository_structure_test.dart", + "sha256": "b9272c637e764758f71192bfcfa8a1a90d9db8928f1c76d928dada56ee51686b", + "member": "test/repository_structure_test.dart" + }, + { + "path": "test/tool/test_android_notification_manifest.py", + "sha256": "6d51cf68d64339b9159ffa35c693c5edd65dc37e5827235b5913a168508454e7", + "member": "test/tool/test_android_notification_manifest.py" + }, + { + "path": "tool/check_android_notification_manifest.py", + "sha256": "3cdce3147005cd9d789bea4b7df49623f2cfe84d8f0dee2cafdf5f3a88570479", + "member": "tool/check_android_notification_manifest.py" + }, + { + "path": "tool/check_coverage.dart", + "sha256": "4e124968dd49d06f20c17db2a0f70b13712a65ecc47c1eae24618b1d929356f5", + "member": "tool/check_coverage.dart" + }, + { + "path": "tool/check_database_schema.dart", + "sha256": "1a39e2ef577a45bd5ac4938a95e0946781287e9a5d86606cd2d2026740cc5210", + "member": "tool/check_database_schema.dart" + }, + { + "path": "tool/check_generated_dart_policy.dart", + "sha256": "81e732eba020741605fd7352af7cacb876c033d20478ad0e1cb7f48af36f1662", + "member": "tool/check_generated_dart_policy.dart" + }, + { + "path": "tool/check_local_only_boundary.dart", + "sha256": "d40d2362d02309d5ddc57a9b426a1d56eb0e90735b13b7039661e6ff92cdc05c", + "member": "tool/check_local_only_boundary.dart" + }, + { + "path": "tool/database_schema_builder.dart", + "sha256": "932e8eac0e407f8eb7578d005524b832d5289df46268acd0495d19ab216a97e1", + "member": "tool/database_schema_builder.dart" + }, + { + "path": "tool/database_schema_input.dart", + "sha256": "0d58fe9b11889926484d347ca0f03537b2726520809a45b812b0e556d0b5a068", + "member": "tool/database_schema_input.dart" + }, + { + "path": "tool/qa/android_sqlite_control.kt", + "sha256": "2c19a70e359eff06da69c58baa176663cc33888517bbcd9d9e29a28bb0843dda", + "member": "tool/qa/android_sqlite_control.kt" + }, + { + "path": "tool/qa/backup_native_probe.dart", + "sha256": "bb0393d435a8fabc00be832f6580dd1805be7b0be42ac5211fcabde1ff5e9769", + "member": "tool/qa/backup_native_probe.dart" + }, + { + "path": "tool/qa/capture_damaged_store_recovery.dart", + "sha256": "8dbcf086dbb3f6071b4b73b7c7af26d0ec4419f6100b293fb25a75e6a3bbc48f", + "member": "tool/qa/capture_damaged_store_recovery.dart" + }, + { + "path": "tool/qa/capture_default_preferences.dart", + "sha256": "b6c61ae5f7fa8ca02740edd668ab74625d3652823cda9bf2f12d8002485f9842", + "member": "tool/qa/capture_default_preferences.dart" + }, + { + "path": "tool/qa/capture_local_data_workflows.dart", + "sha256": "9230da65ac4af641645da2d46172a39b8b63bc132d9e656c6955adc03009935d", + "member": "tool/qa/capture_local_data_workflows.dart" + }, + { + "path": "tool/qa/capture_preparation_start.dart", + "sha256": "170ea3f55f54030b2f7b6159c053704f15fd2d6a3c74d6c85518b337114fed45", + "member": "tool/qa/capture_preparation_start.dart" + }, + { + "path": "tool/qa/capture_reset_recovery.dart", + "sha256": "97cd0c27cc9cadcfb1d4f21dd5c894ec88e3eb84f3fd57ac0c151551594358dd", + "member": "tool/qa/capture_reset_recovery.dart" + }, + { + "path": "tool/qa/capture_restore_cleanup.dart", + "sha256": "b3a0aa1ff4042ee5d6e947eefd89f3e62284145c911f91b015e851ce6a4237d5", + "member": "tool/qa/capture_restore_cleanup.dart" + }, + { + "path": "tool/qa/emit_database_schema.dart", + "sha256": "13eef4d0d95ce80ffe3990c46b4c26a8ce98ed9790f40501686cef8ccf1df864", + "member": "tool/qa/emit_database_schema.dart" + }, + { + "path": "tool/qa/migration_crash_probe.dart", + "sha256": "51f6c7fec8badc882b46e5b73dd291077bdf4ffb5f14ea589cf28da4fd830938", + "member": "tool/qa/migration_crash_probe.dart" + }, + { + "path": "tool/qa/migration_native_probe.dart", + "sha256": "5c9dff1d9b18cd38f717ffa9d70f289324417db470a90365ff6fa464649a067f", + "member": "tool/qa/migration_native_probe.dart" + }, + { + "path": "tool/qa/recovery_pair_crash_probe.dart", + "sha256": "c760ca688e2f0e15cd59b7e11edcc6fee058fd50af8e66d05965852aebcec2ce", + "member": "tool/qa/recovery_pair_crash_probe.dart" + }, + { + "path": "tool/qa/reset_journal_crash_probe.dart", + "sha256": "5374f12a5f0226a6d65b2359597da91002d50776f6d3ae6e1b736a55bc87405d", + "member": "tool/qa/reset_journal_crash_probe.dart" + }, + { + "path": "tool/qa/schedule_deletion_crash_probe.dart", + "sha256": "e327ee8f73ba1f67784235dd9702d7b60ee1ff84fc86c977d511d57bd8aee0ff", + "member": "tool/qa/schedule_deletion_crash_probe.dart" + }, + { + "path": "tool/qa/time_zone_native_probe.dart", + "sha256": "32a74cbe3af334d693e09089a51523058df0f2f4131452e2791ada0c8b0ff369", + "member": "tool/qa/time_zone_native_probe.dart" + }, + { + "path": "tool/qa/verify_migration_crash_recovery.py", + "sha256": "7b3565dcfbf2e293f611aa76c7ae5c904165990c2f2deefd82709e53a33389ff", + "member": "tool/qa/verify_migration_crash_recovery.py" + }, + { + "path": "tool/qa/verify_pair_crash_recovery.py", + "sha256": "06a06f595e9d92a263c23f757495a70994927572b2914c1d4a603c13365ddb88", + "member": "tool/qa/verify_pair_crash_recovery.py" + }, + { + "path": "tool/qa/verify_reset_crash_recovery.py", + "sha256": "daa52732605ec0b75c165bf453d2085ca0f7eb03380e37162f40340ebab4de69", + "member": "tool/qa/verify_reset_crash_recovery.py" + }, + { + "path": "tool/qa/verify_schedule_deletion_crash_recovery.py", + "sha256": "3daa8ecd8bd4eba08fa4646359e6f16147b724084e36035f28f38b160b9cb61a", + "member": "tool/qa/verify_schedule_deletion_crash_recovery.py" + }, + { + "path": "tool/qa/verify_schema_gate.py", + "sha256": "a57aeab77b4855ba69224d0b6ef78110a8bbd945cfdc00e0602fa8b8e56b323e", + "member": "tool/qa/verify_schema_gate.py" + }, + { + "path": "web/index.html", + "sha256": "2e92775c88c012f6307f0f7ea42fa79cd0d6144234e805b4f308801daa58cb8e", + "member": "web/index.html" + } + ], + "verifiedSaved": 786, + "liveDriftAtArchive": [], + "ignoredGeneratedHashOnly": 10 +} diff --git a/plans/pr-verified-checkpoint-2026-09-25/verification.json b/plans/pr-verified-checkpoint-2026-09-25/verification.json new file mode 100644 index 00000000..f48446b6 --- /dev/null +++ b/plans/pr-verified-checkpoint-2026-09-25/verification.json @@ -0,0 +1,63 @@ +{ + "implementation_commit": "c8f09ce662271f9d127f38f89d8535ba4eac176b", + "status": "code_verified_device_pending", + "scope": "T03 local host actual SQLCipher full suite and CI; device and remote OPEN", + "source_manifest": "source-manifest.json", + "inputs": 786, + "root_git_blob_verification": { + "commit": "c8f09ce662271f9d127f38f89d8535ba4eac176b", + "tracked_git_blobs_equal": 776, + "ignored_generated_live_hashes_equal": 10, + "mismatches": [] + }, + "full": { + "passed": 2090, + "conditional_skipped": 6, + "failed": 0, + "exit_code": 0, + "terminal": "06:29 +2090 ~6: All tests passed!", + "interrupted": false + }, + "analyze": { + "issues": 0 + }, + "local_ci": { + "checks": 6, + "all_exit_zero": true + }, + "root_independent_coverage_recount": { + "app_owned_covered": 24994, + "app_owned_total": 27871, + "app_owned_percent": 89.67744250296006, + "raw_unfiltered_covered": 27398, + "raw_unfiltered_total": 34054, + "raw_unfiltered_percent": 80.45457215011453 + }, + "targeted": { + "crypto_and_parser": 27, + "workflow": 70, + "identity_final": 28, + "counts_overlap_full": true + }, + "conditional_skips": "4 explicit timezone environments plus 2 inverse plaintext cases; unchanged historical supplemental evidence is not added to this run", + "host_reset": { + "source_commit": "729730d078cb2af7683e5823faa893e66cb6fd36", + "actual_sigkill_cases_passed": 11, + "scope": "host process death; fixture providers and deletion adapters", + "repeated_after_identity_ticket": false, + "reason": "Identity publication patch does not change the reset crash probe; preserve actual prior-source evidence." + }, + "defects_fixed": [ + "D05: authenticated final frame followed by extra frame escaped as untyped Sodium StateError", + "A09: actual restore COMMIT response loss was classified as notCommitted without authoritative readback", + "A09: stale identity.load republished old runtime authority after unknown restore hold" + ], + "mobile": "OPEN: actual Android/iOS failure-preservation-user-retry, native providers, secure storage, capacity measurements", + "provenance_note": "Prior local validation at the exact source checkpoint. Product/test/build inputs in this PR match all 776 tracked inputs; 10 ignored generated inputs are recorded as hashes only. Remote checks for this new PR are separate.", + "remaining_acceptance": [ + "Physical Android/iOS notifications, permissions, lifecycle, native file providers, secure storage and accessibility remain open.", + "Full U08 screen/locale/text scale coverage and remaining audit items are not completed by this PR.", + "T04 implementation and all later edits are excluded.", + "No merge, store release, or issue closure is performed by opening this PR." + ] +} diff --git a/pubspec.lock b/pubspec.lock index 859918c4..fa259a1c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,7 +73,7 @@ packages: source: hosted version: "2.1.2" build: - dependency: transitive + dependency: "direct dev" description: name: build sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 @@ -208,14 +208,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 - url: "https://pub.dev" - source: hosted - version: "0.3.5+5" crypto: dependency: "direct main" description: @@ -289,7 +281,7 @@ packages: source: hosted version: "2.0.8" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -320,70 +312,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_selector: - dependency: "direct main" - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.dev" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206" - url: "https://pub.dev" - source: hosted - version: "0.5.2+10" - file_selector_ios: - dependency: "direct dev" - description: - name: file_selector_ios - sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" - url: "https://pub.dev" - source: hosted - version: "0.5.3+6" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab - url: "https://pub.dev" - source: hosted - version: "0.9.4+1" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 - url: "https://pub.dev" - source: hosted - version: "0.9.5+1" - file_selector_platform_interface: - dependency: "direct dev" - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec - url: "https://pub.dev" - source: hosted - version: "0.9.3+6" fixnum: dependency: transitive description: @@ -1037,7 +965,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" @@ -1130,7 +1058,7 @@ packages: source: hosted version: "0.6.8" sqlite3: - dependency: "direct dev" + dependency: "direct main" description: name: sqlite3 sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" @@ -1347,4 +1275,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index e601b485..a6c3c1a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: formz: ^0.8.0 dotted_line: ^3.2.3 sqlcipher_flutter_libs: 0.6.8 + sqlite3: ^2.9.4 flutter_local_notifications: ^20.1.0 timezone: ^0.10.0 intl: ^0.20.2 @@ -71,19 +72,17 @@ dependencies: package_info_plus: ^10.2.0 path: ^1.9.1 path_provider: ^2.1.5 - file_selector: ^1.0.4 sodium: 4.0.4 unorm_dart: ^0.3.2 dev_dependencies: - sqlite3: ^2.9.4 + fake_async: 1.3.3 + build: 4.0.6 + shared_preferences_platform_interface: 2.4.1 flutter_test: sdk: flutter - # Exercise the locked iOS picker contract without replacing its UTI logic. - file_selector_ios: 0.5.3+6 - file_selector_platform_interface: 2.7.0 drift_dev: ^2.31.0 diff --git a/test/core/backup/backup_admission_test.dart b/test/core/backup/backup_admission_test.dart new file mode 100644 index 00000000..8248fdaf --- /dev/null +++ b/test/core/backup/backup_admission_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +void main() { + for (final entry in [ + ('schedules', BackupLimits.schedules, 'schedules'), + ('templates', BackupLimits.records, 'records'), + ]) { + test( + 'actual ${entry.$2} ${entry.$3} cap admits input entries before disk allocation including duplicate IDs', + () async { + final sink = _Counter(); + final budget = BackupBudget(); + Stream> input() async* { + yield utf8.encode('{"${entry.$1}":['); + for (var i = 0; i <= entry.$2; i++) { + yield utf8.encode('${i == 0 ? '' : ','}{"id":"same"}'); + } + yield utf8.encode(']}'); + } + + await expectLater( + BackupJsonReader(sink, budget, portableRecords: true).read(input()), + throwsA( + isA().having( + (e) => e.limit, + 'limit', + entry.$3, + ), + ), + ); + expect(budget.records, entry.$2); + // root+array and exactly two nodes per admitted record; rejected record + // has not reached the sink even though its duplicate identity is unknown. + expect(sink.nodes, 2 + 2 * entry.$2); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + } + test( + 'original UTF8 identifier limit applies while reading token before value write', + () async { + final sink = _Counter(); + final budget = BackupBudget(); + await expectLater( + BackupJsonReader(sink, budget, portableRecords: true).read( + Stream.value(utf8.encode('{"schedules":[{"id":"${'a' * 513}"}]}')), + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(sink.nodes, 3); + }, + ); + test('unknown fields still consume logical node work and depth', () async { + final budget = BackupBudget(); + final sink = _Counter(); + await BackupJsonReader( + sink, + budget, + portableRecords: true, + ).read(Stream.value(utf8.encode('{"unknown":[1,2,3]}'))); + expect(budget.work, 5); + expect(budget.records, 0); + }); +} + +class _Counter implements BackupJsonSink { + var nodes = 0; + @override + int writeNode(int? parent, String? key, String kind, Object? value) => + ++nodes; +} diff --git a/test/core/backup/backup_authenticated_time_review_test.dart b/test/core/backup/backup_authenticated_time_review_test.dart new file mode 100644 index 00000000..c01c2f5d --- /dev/null +++ b/test/core/backup/backup_authenticated_time_review_test.dart @@ -0,0 +1,594 @@ +// Authenticated low-level owner and shared-budget contracts. The ready adapter +// here owns the ingestion only; service/SQLCipher/native proof is separate. +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:timezone/timezone.dart' as tz; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_validation_contract_test.dart' show validBackup; +import 'backup_validated_ingestion_test.dart' show recurringBackup; + +class _Ready extends BackupRestoreInput { + _Ready(this.data, this.lease); + final BackupValidatedIngestion data; + final BackupProcessingLease lease; + @override + BackupRestorePreview get preview => data.preview; + @override + Future dispose() async { + await data.release(); + lease.release(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const zone = 'Test/A10_Review_Owned'; + late BackupCrypto crypto; + late BackupProcessingOwner owner; + late DateTime now; + late Map locations; + final selections = []; + late BackupIngestionStore store; + var readyCalls = 0; + void Function()? duringReady; + Future Function()? cleanup; + Map input() { + final value = jsonDecode(jsonEncode(validBackup())) as Map; + value['schedules'][0].addAll({ + 'civilTime': '2031-01-02T09:00:00.123456', + 'timeZoneId': 'Removed/Zone', + 'occurrenceOffsetSeconds': 0, + }); + return value; + } + + setUp(() { + TimeZoneRules.ensureInitialized(); + locations = Map.of(tz.timeZoneDatabase.locations); + tz.timeZoneDatabase.locations[zone] = tz.Location(zone, [], [], [ + const tz.TimeZone(0, isDst: false, abbreviation: 'A'), + ]); + now = DateTime.utc(2030); + crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + owner = BackupProcessingOwner(); + readyCalls = 0; + duringReady = null; + cleanup = null; + selections.clear(); + }); + tearDown(() async { + for (final selected in selections.reversed) { + await selected.dispose(); + } + tz.timeZoneDatabase.locations + ..clear() + ..addAll(locations); + }); + Future open(Map value) async { + final encrypted = await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(value))), + password: 'review boundary password', + ); + final lease = owner.acquire(); + final selected = await BackupAuthenticatedTimeReview.decrypt( + ciphertext: Stream.value(encrypted), + password: 'review boundary password', + crypto: crypto, + budget: BackupBudget(lease: lease), + createStore: (budget) async => + store = BackupIngestionStore.memoryForTesting( + budget, + onRelease: () async { + await cleanup?.call(); + }, + ), + now: () => now, + ready: (data) async { + readyCalls++; + duringReady?.call(); + return _Ready(data, lease); + }, + ); + selections.add(selected); + expect(selected, isA()); + return selected as BackupTimeReviewInput; + } + + Future field( + BackupTimeReviewInput review, { + String selectedZone = 'UTC', + String? civil, + }) async { + final issue = (await review.issues()).items.first; + final summary = review.summary; + return review.review( + BackupTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + civil: civil == null ? issue.civil : CivilDateTime.parse(civil), + zone: selectedZone, + ), + ); + } + + test( + 'loaded rule mutation retires a choice without editing candidate', + () async { + final review = await open(input()); + final selected = await field(review, selectedZone: zone); + final revision = review.summary.revision; + tz.timeZoneDatabase.locations[zone]!.zones[0] = const tz.TimeZone( + 3600000, + isDst: false, + abbreviation: 'B', + ); + await expectLater( + review.choose(BackupTimeChoice(review: selected, offsetSeconds: 0)), + throwsA(isA()), + ); + expect(review.summary.revision, revision); + expect(readyCalls, 0); + final refreshed = await review.revalidate(); + expect(refreshed, same(review)); + expect( + (await field(review, selectedZone: zone)).choices.single.offsetSeconds, + 3600, + ); + }, + ); + test( + 'rule change during ready construction cannot escape as verified', + () async { + final review = await open(input()); + await review.choose( + BackupTimeChoice( + review: await field(review, selectedZone: zone), + offsetSeconds: 0, + ), + ); + duringReady = () => tz.timeZoneDatabase.locations[zone]!.zones[0] = + const tz.TimeZone(3600000, isDst: false, abbreviation: 'B'); + final result = await review.revalidate().then((v) { + selections.add(v); + return v; + }, onError: (Object e) => e); + expect(result, isA()); + expect(owner.active, isNull); + }, + ); + test('gap remains a proposal until explicit valid civil selection', () async { + final review = await open(input()); + final gap = await field( + review, + selectedZone: 'America/New_York', + civil: '2031-03-09T02:30:00.123456', + ); + expect(gap.choices, isEmpty); + expect(gap.nextValidCivil, CivilDateTime.parse('2031-03-09T03:00:00')); + await expectLater( + review.choose(BackupTimeChoice(review: gap, offsetSeconds: -14400)), + throwsA(isA()), + ); + expect(readyCalls, 0); + final next = await field( + review, + selectedZone: 'America/New_York', + civil: gap.nextValidCivil!.toCivilIso8601String(), + ); + await review.choose(BackupTimeChoice(review: next, offsetSeconds: -14400)); + final ready = await review.revalidate(); + selections.add(ready); + expect(ready, isA()); + }); + test( + 'overlap requires one actual candidate and preserves microseconds', + () async { + final review = await open(input()); + final overlap = await field( + review, + selectedZone: 'America/New_York', + civil: '2031-11-02T01:30:00.123456', + ); + expect(overlap.choices.map((c) => c.offsetSeconds), [-14400, -18000]); + await expectLater( + review.choose(BackupTimeChoice(review: overlap, offsetSeconds: 0)), + throwsA(isA()), + ); + await review.choose( + BackupTimeChoice(review: overlap, offsetSeconds: -18000), + ); + final issue = (await review.issues()).items.single; + expect(issue.currentLiteral, '2031-11-02T01:30:00.123456'); + expect(issue.originalLiteral, '2031-01-02T09:00:00.123456'); + expect(issue.selectedOffsetSeconds, -18000); + }, + ); + test( + 'recurring scalar zone choice preserves association and records the time override', + () async { + final value = recurringBackup(year: 2031); + value['schedules'][0]['occurrenceOffsetSeconds'] = 3600; + final review = await open(value); + final choice = await field(review, selectedZone: 'Asia/Seoul'); + expect(choice.conflictsByOffset[32400]!.conflicts, isEmpty); + await review.choose( + BackupTimeChoice(review: choice, offsetSeconds: 32400), + ); + final result = await review.revalidate(); + selections.add(result); + expect(result, isA()); + final row = store.record('schedule', 's1')!; + final fields = store.fields(row['node'] as int, { + 'id', + 'recurringSegmentId', + 'recurringSlotKey', + 'recurringOrdinal', + 'recurringOverrides', + 'timeZoneId', + 'occurrenceOffsetSeconds', + }); + expect(fields['recurringSegmentId'], 'seg'); + expect(fields['recurringOrdinal'], 3); + expect(fields['recurringOverrides'], 'time'); + expect(fields['timeZoneId'], 'Asia/Seoul'); + expect(fields['occurrenceOffsetSeconds'], 32400); + }, + ); + test( + 'unmaterialized recurrence includes its explicit spare in its busy interval', + () async { + final value = recurringBackup(year: 2031); + value['schedules'] = input()['schedules']; + value['schedules'][0]['civilTime'] = '2031-01-01T08:30:00'; + value['profile']['spareTimeMinutes'] = 60; + final segment = value['recurring']['segments'][0]; + final prototype = + jsonDecode(segment['scheduleJson'] as String) as Map; + prototype['spare'] = 60; + segment['scheduleJson'] = jsonEncode(prototype); + final rule = + jsonDecode(segment['ruleJson'] as String) as Map; + rule['count'] = 1; + segment['ruleJson'] = jsonEncode(rule); + final review = await open(value); + final choice = await field(review); + expect(choice.conflictsByOffset[0]!.conflicts, hasLength(1)); + await expectLater( + review.choose(BackupTimeChoice(review: choice, offsetSeconds: 0)), + throwsA(isA()), + ); + expect(readyCalls, 0); + }, + ); + test( + 'unresolved materialized override occupies its base slot without phantom conflict', + () async { + final value = recurringBackup(year: 2031); + final override = value['schedules'][0]; + override.addAll({ + 'civilTime': '2031-01-03T13:00:00', + 'timeZoneId': 'Removed/Zone', + 'recurringOverrides': 'time', + }); + final target = input()['schedules'][0]; + target.addAll({'id': 'target', 'civilTime': '2031-01-03T09:00:00'}); + value['schedules'] = [target, override]; + final review = await open(value); + final choice = await field(review); + final proof = choice.conflictsByOffset[0]!; + expect(proof.conflicts, isEmpty); + expect(proof.possibleOverlaps.map((item) => item.id), ['s1']); + await review.choose( + BackupTimeChoice( + review: choice, + offsetSeconds: 0, + acknowledgedPossibleIds: {'s1'}, + ), + ); + expect(await review.revalidate(), same(review)); + expect(review.summary.issueCount, 1); + expect(readyCalls, 0); + }, + ); + test( + 'past first overlap candidate does not suppress future second choice', + () async { + now = DateTime.utc(2030, 11, 3, 5, 35); + final review = await open(input()); + final overlap = await field( + review, + selectedZone: 'America/New_York', + civil: '2030-11-03T01:30:00', + ); + expect(overlap.choices.map((c) => c.offsetSeconds), [-18000]); + expect(overlap.conflictsByOffset.keys, [-18000]); + await expectLater( + review.choose(BackupTimeChoice(review: overlap, offsetSeconds: -14400)), + throwsA(isA()), + ); + await review.choose( + BackupTimeChoice(review: overlap, offsetSeconds: -18000), + ); + final ready = await review.revalidate(); + selections.add(ready); + expect(ready, isA()); + }, + ); + test( + 'two instant fields per parent cross a page without duplicate or omitted issue', + () async { + final value = input(); + value['schedules'] = []; + value['templates'] = [ + for (var i = 0; i < 12; i++) + { + 'id': 'template$i', + 'name': 'Template$i', + 'createdAt': '2020-01-01T10:00:00', + 'updatedAt': '2020-01-02T10:00:00', + 'preparation': [ + {'id': 'step$i', 'name': 'Step', 'minutes': 1, 'nextId': null}, + ], + }, + ]; + final review = await open(value); + expect(review.summary.issueCount, 24); + final first = await review.issues(); + final second = await review.issues(cursor: first.nextCursor); + expect(first.items, hasLength(20)); + expect(second.items, hasLength(4)); + expect(second.nextCursor, isNull); + final all = [...first.items, ...second.items]; + expect(all.map((i) => i.id).toSet(), hasLength(24)); + expect(all.map((i) => i.fieldPath).toSet(), hasLength(24)); + expect( + all.every( + (i) => i.reason == BackupTimeIssueReason.missingInstantOffset, + ), + isTrue, + ); + await review.choose( + BackupTimeChoice( + review: await field(review, selectedZone: 'Asia/Seoul'), + offsetSeconds: 32400, + ), + ); + expect((await review.revalidate()), same(review)); + expect(review.summary.issueCount, 23); + expect(readyCalls, 0); + }, + ); + test( + 'invented field review cannot authorize arbitrary JSON changes', + () async { + final review = await open(input()); + final actual = await field(review); + final forged = BackupTimeFieldReview( + draft: actual.draft, + issue: actual.issue, + choices: actual.choices, + ); + await expectLater( + review.choose(BackupTimeChoice(review: forged, offsetSeconds: 0)), + throwsA(isA()), + ); + expect(readyCalls, 0); + expect( + (await review.issues()).items.single.currentLiteral, + actual.issue.currentLiteral, + ); + }, + ); + for (final phase in ['before choose', 'before revalidation']) { + test('preparation boundary $phase retires old confirmation', () async { + final value = input(); + value['schedules'][0].addAll({ + 'civilTime': '2030-01-01T01:00:00', + 'moveTimeMinutes': 30, + }); + final review = await open(value); + final selected = await field(review); + if (phase == 'before revalidation') { + await review.choose( + BackupTimeChoice(review: selected, offsetSeconds: 0), + ); + } + now = DateTime.utc(2030, 1, 1, 0, 30); + await expectLater( + phase == 'before choose' + ? review.choose( + BackupTimeChoice(review: selected, offsetSeconds: 0), + ) + : review.revalidate(), + throwsA(isA()), + ); + expect(readyCalls, 0); + }); + } + test('target passing after review cannot become a historical edit', () async { + final review = await open(input()); + final selected = await field(review); + now = DateTime.utc(2032); + await expectLater( + review.choose(BackupTimeChoice(review: selected, offsetSeconds: 0)), + throwsA(isA()), + ); + expect(readyCalls, 0); + }); + test('failed terminal cleanup retries under the same lease', () async { + var attempts = 0; + cleanup = () async { + if (++attempts == 1) throw StateError('owned cleanup failure'); + }; + final review = await open(input()); + final lease = owner.active; + await expectLater(review.dispose(), throwsStateError); + expect(owner.active, same(lease)); + expect(lease!.phase, BackupProcessingPhase.cleanupPending); + await review.dispose(); + expect(attempts, 2); + expect(owner.active, isNull); + }); + test( + 'external cancellation after ready allocation retires the late owner', + () async { + var released = 0; + cleanup = () async { + released++; + }; + final review = await open(input()); + await review.choose( + BackupTimeChoice(review: await field(review), offsetSeconds: 0), + ); + duringReady = owner.requestCancellation; + await expectLater( + review.revalidate(), + throwsA( + isA().having( + (e) => e.kind, + 'cancelled', + BackupFailureKind.userCancelled, + ), + ), + ); + expect(released, 1); + expect(owner.active, isNull); + }, + ); + test( + 'same instant known conflict blocks scalar edit without changing original', + () async { + final value = input(); + final other = + jsonDecode(jsonEncode(value['schedules'][0])) as Map; + other.addAll({ + 'id': 's2', + 'name': 'Other', + 'timeZoneId': 'UTC', + 'occurrenceOffsetSeconds': 0, + }); + value['schedules'].add(other); + final review = await open(value); + final selected = await field(review); + expect( + selected.conflictsByOffset[0]!.conflicts.single.other.schedule.id, + 's2', + ); + await expectLater( + review.choose(BackupTimeChoice(review: selected, offsetSeconds: 0)), + throwsA(isA()), + ); + expect((await review.issues()).items.single.zone, 'Removed/Zone'); + expect(readyCalls, 0); + }, + ); + test( + 'possible overlap requires exact acknowledgement and cannot grant final ready', + () async { + final value = input(); + final other = + jsonDecode(jsonEncode(value['schedules'][0])) as Map; + other.addAll({'id': 's2', 'name': 'Other unknown'}); + value['schedules'].add(other); + final review = await open(value); + final selected = await field(review); + expect(selected.conflictsByOffset[0]!.conflicts, isEmpty); + expect(selected.conflictsByOffset[0]!.possibleOverlaps.single.id, 's2'); + await expectLater( + review.choose(BackupTimeChoice(review: selected, offsetSeconds: 0)), + throwsA(isA()), + ); + await expectLater( + review.choose( + BackupTimeChoice( + review: selected, + offsetSeconds: 0, + acknowledgedPossibleIds: {'s2', 'invented'}, + ), + ), + throwsA(isA()), + ); + await review.choose( + BackupTimeChoice( + review: selected, + offsetSeconds: 0, + acknowledgedPossibleIds: {'s2'}, + ), + ); + expect(await review.revalidate(), same(review)); + expect(review.summary.issueCount, 1); + expect(readyCalls, 0); + }, + ); + test( + 'uncertain historical record does not block an independent future correction', + () async { + final value = input(); + final other = + jsonDecode(jsonEncode(value['schedules'][0])) as Map; + other.addAll({ + 'id': 's2', + 'name': 'History', + 'civilTime': '2020-01-01T09:00:00', + 'occurrenceOffsetSeconds': null, + }); + value['schedules'].add(other); + final review = await open(value); + final selected = await field(review); + expect(selected.conflictsByOffset[0]!.possibleOverlaps, isEmpty); + await review.choose(BackupTimeChoice(review: selected, offsetSeconds: 0)); + final ready = await review.revalidate(); + selections.add(ready); + expect(ready, isA()); + }, + ); + test( + 'effective default preparation bounds final selection instead of treating missing own steps as zero', + () async { + final value = input(); + value['schedules'][0]['civilTime'] = '2030-01-01T01:00:00'; + value['defaultPreparation'][0]['minutes'] = 30; + final review = await open(value); + final selected = await field(review); + now = DateTime.utc(2030, 1, 1, 0, 30); + await expectLater( + review.choose(BackupTimeChoice(review: selected, offsetSeconds: 0)), + throwsA(isA()), + ); + expect(readyCalls, 0); + }, + ); + test('all review passes share the original finite work budget', () async { + final review = await open(input()); + final before = store.budget.work; + await review.revalidate(); + expect(store.budget.work, greaterThan(before)); + store.budget.visit(BackupLimits.work - store.budget.work); + await expectLater( + review.revalidate(), + throwsA( + isA().having( + (e) => e.kind, + 'work limit', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(readyCalls, 0); + expect(review.summary.independentStructureChecked, isFalse); + await expectLater(review.issues(), throwsA(isA())); + }); +} diff --git a/test/core/backup/backup_civil_persistence_test.dart b/test/core/backup/backup_civil_persistence_test.dart new file mode 100644 index 00000000..075103af --- /dev/null +++ b/test/core/backup/backup_civil_persistence_test.dart @@ -0,0 +1,193 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +// Structured synthetic QA evidence is intentionally emitted to captured logs. +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/backup/backup_export_snapshot.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/utils/json_converters/duration_json_converters.dart'; +import 'backup_validation_contract_test.dart' show validBackup; +import 'backup_validated_ingestion_test.dart' show recurringBackup; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'foreign civil gap preserves raw DB and portable reexport through candidate copy', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0]['civilTime'] = '2026-03-08T02:30:00.123456'; + input['schedules'][0]['timeZoneId'] = 'Asia/Seoul'; + final budget = BackupBudget(); + final raw = BackupIngestionStore.memoryForTesting(budget); + addTearDown(raw.release); + final node = await BackupJsonReader( + raw, + budget, + ).read(Stream.value(utf8.encode(jsonEncode(input)))); + final content = await BackupValidatedIngestion.validateTestStore( + raw, + node, + nowUtc: DateTime.utc(2026, 9, 24), + ); + final candidate = AppDatabase.forTesting(NativeDatabase.memory()); + final active = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(candidate.close); + addTearDown(active.close); + await content.materialize(candidate, pendingCleanup: false); + await content.validateReadBack(candidate, pendingCleanup: false); + await copyPortableBackupRows(candidate, active, budget: BackupBudget()); + for (final database in [candidate, active]) { + final stored = + (await database + .customSelect('SELECT schedule_time FROM schedules') + .getSingle()) + .read('schedule_time'); + expect(stored, '2026-03-08T02:30:00.123456'); + final snapshot = await BackupExportSnapshot.create( + database, + cutoff: DateTime.utc(2026, 9, 24), + sourceAppVersion: 'test', + sourcePlatform: 'test', + budget: BackupBudget(), + stagingFactory: () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(db, db.close); + }, + ); + try { + final json = jsonDecode( + utf8.decode(await snapshot.plaintext().expand((c) => c).toList()), + ); + expect(json['schedules'][0]['civilTime'], stored); + expect(json['schedules'][0]['occurrenceOffsetSeconds'], 32400); + } finally { + await snapshot.release(); + } + } + // A10 now reads the preserved wall fields through a neutral carrier. + // The instant still requires the stored explicit offset. + final typed = + (await active.select(active.schedules).getSingle()).scheduleTime; + expect( + CivilDateTime.fromFields(typed), + CivilDateTime.parse('2026-03-08T02:30:00.123456'), + ); + expect( + CivilDateTime.fromFields(typed).atOffset(32400), + DateTime.utc(2026, 3, 7, 17, 30, 0, 123, 456), + ); + print( + 'D05_RAW_CIVIL localHour=${typed.hour} zone=${typed.timeZoneName} rawHour=2', + ); + }, + ); + test( + 'recurring civil slots, exclusion and explicit completion survive portable boundary', + () async { + final input = recurringBackup(ordinal: 2); + const start = '2026-03-07T02:30:00'; + const slot = '2026-03-08T02:30:00'; + final schedule = input['schedules'][0]; + schedule.addAll({ + 'civilTime': slot, + 'recurringSlotKey': slot, + 'timeZoneId': 'Asia/Seoul', + 'occurrenceOffsetSeconds': 32400, + 'finishedAt': '2026-03-07T17:30:00Z', + }); + final segment = input['recurring']['segments'][0]; + segment['fromSlot'] = start; + final rule = jsonDecode(segment['ruleJson']); + rule['start'] = start; + rule['zone'] = 'Asia/Seoul'; + segment['ruleJson'] = jsonEncode(rule); + final base = jsonDecode(segment['scheduleJson']); + base['time'] = start; + base['zone'] = 'Asia/Seoul'; + base['offset'] = 32400; + segment['scheduleJson'] = jsonEncode(base); + input['recurring']['exclusions'].add({ + 'segmentId': 'seg', + 'slotKey': '2026-03-09T02:30:00', + 'ordinal': 3, + }); + final budget = BackupBudget(); + final raw = BackupIngestionStore.memoryForTesting(budget); + addTearDown(raw.release); + final node = await BackupJsonReader( + raw, + budget, + ).read(Stream.value(utf8.encode(jsonEncode(input)))); + final content = await BackupValidatedIngestion.validateTestStore( + raw, + node, + nowUtc: DateTime.utc(2026, 9, 24), + ); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await content.materialize(db, pendingCleanup: true); + await content.validateReadBack(db, pendingCleanup: true); + final row = + (await db + .customSelect( + 'SELECT schedule_time,recurring_slot_key,finished_at FROM schedules', + ) + .getSingle()) + .data; + expect(row['schedule_time'], '2026-03-08T02:30:00.000'); + expect(row['recurring_slot_key'], slot); + expect( + row['finished_at'], + DateTime.utc(2026, 3, 7, 17, 30).millisecondsSinceEpoch ~/ 1000, + ); + final snapshot = await BackupExportSnapshot.create( + db, + cutoff: DateTime.utc(2026, 9, 24), + sourceAppVersion: 'test', + sourcePlatform: 'test', + budget: BackupBudget(), + stagingFactory: () async { + final target = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(target, target.close); + }, + ); + try { + final exported = jsonDecode( + utf8.decode(await snapshot.plaintext().expand((c) => c).toList()), + ); + expect(exported['recurring'], input['recurring']); + expect( + exported['schedules'][0]['civilTime'], + '2026-03-08T02:30:00.000', + ); + expect( + exported['schedules'][0]['finishedAt'], + '2026-03-07T17:30:00.000Z', + ); + } finally { + await snapshot.release(); + } + }, + ); + test( + 'civil converter preserves fields while countdown requires an explicit offset', + () { + const converter = CivilDateTimeSqlConverter(); + final actual = converter.fromSql('2026-09-24T09:00:00.000'); + final now = DateTime.utc(2026, 9, 23, 23); + expect(actual.isUtc, true); + expect( + CivilDateTime.fromFields(actual).atOffset(32400).difference(now), + const Duration(hours: 1), + ); + expect(converter.toSql(actual), '2026-09-24T09:00:00.000'); + }, + ); +} diff --git a/test/core/backup/backup_crypto_capacity_test.dart b/test/core/backup/backup_crypto_capacity_test.dart new file mode 100644 index 00000000..1c63e887 --- /dev/null +++ b/test/core/backup/backup_crypto_capacity_test.dart @@ -0,0 +1,94 @@ +// Structured synthetic QA evidence is intentionally emitted to captured logs. +// ignore_for_file: avoid_print +import 'dart:io'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import '../../helpers/sodium_test_loader.dart'; + +void main() { + test( + 'actual 64MiB plaintext round trips with locked sodium without a plaintext spool or joined container', + () async { + final dir = await Directory.systemTemp.createTemp('d05-capacity-'); + addTearDown(() => dir.delete(recursive: true)); + final encrypted = File('${dir.path}/ciphertext'); + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + final chunk = Uint8List.fromList(List.generate(65536, (i) => i % 251)); + Stream> source() async* { + for (var i = 0; i < 1024; i++) { + yield chunk; + } + } + + final writer = encrypted.openWrite(); + final outputBudget = BackupBudget(); + final timer = Stopwatch()..start(); + await writer.addStream( + crypto.encryptStream( + plaintext: source(), + plaintextLength: BackupLimits.plainBytes, + password: 'Synthetic exact capacity password', + budget: outputBudget, + ), + ); + await writer.flush(); + await writer.close(); + expect(outputBudget.plainBytes, BackupLimits.plainBytes); + expect( + await encrypted.length(), + lessThanOrEqualTo(BackupLimits.cipherBytes), + ); + final inputBudget = BackupBudget(); + final hash = await sha256 + .bind( + crypto.decryptStream( + container: encrypted.openRead(), + password: 'Synthetic exact capacity password', + budget: inputBudget, + ), + ) + .single; + expect(hash, await sha256.bind(source()).single); + expect(inputBudget.plainBytes, BackupLimits.plainBytes); + print( + 'D05_ACTUAL_CRYPTO_CAP plain=${inputBudget.plainBytes} cipher=${inputBudget.cipherBytes} elapsedMs=${timer.elapsedMilliseconds} hostPeakRss=${ProcessInfo.maxRss}', + ); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); + test( + 'stream exceeding exact admitted length never completes encryption or emits success', + () async { + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + final chunk = Uint8List(65536); + Stream> source() async* { + for (var i = 0; i < 1024; i++) { + yield chunk; + } + yield [1]; + } + + await expectLater( + crypto + .encryptStream( + plaintext: source(), + plaintextLength: BackupLimits.plainBytes, + password: 'Synthetic exact capacity password', + ) + .drain(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/test/core/backup/backup_crypto_failure_matrix_test.dart b/test/core/backup/backup_crypto_failure_matrix_test.dart new file mode 100644 index 00000000..b571715f --- /dev/null +++ b/test/core/backup/backup_crypto_failure_matrix_test.dart @@ -0,0 +1,389 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +import '../../helpers/sodium_test_loader.dart'; +import 'backup_crypto_stream_test.dart' as fixture; + +// Contract: no modified or incomplete authenticated container may successfully +// finish decryption. Partial stream chunks are provisional, never preview-ready. +const _password = 'Synthetic T03 matrix password'; +Uint8List _u32(int n) => (ByteData(4)..setUint32(0, n)).buffer.asUint8List(); +int _headerLength(Uint8List data) => + ByteData.sublistView(data, 8, 12).getUint32(0); +Uint8List _header(Uint8List data) => + Uint8List.sublistView(data, 12, 12 + _headerLength(data)); +Uint8List _replaceHeader(Uint8List data, List header) => + Uint8List.fromList([ + ...data.take(8), + ..._u32(header.length), + ...header, + ...data.skip(12 + _headerLength(data)), + ]); +Uint8List _flip(Uint8List data, int at) { + final copy = Uint8List.fromList(data); + copy[at] ^= 1; + return copy; +} + +Uint8List _setNumber(Uint8List data, int offset, int n) { + final copy = Uint8List.fromList(data); + copy.setRange(offset, offset + 4, _u32(n)); + return copy; +} + +void main() { + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + final plaintext = Uint8List.fromList(List.generate(131077, (i) => i % 251)); + final shortPlaintext = Uint8List.fromList( + utf8.encode('Synthetic matrix bytes'), + ); + late Uint8List encrypted; + late Uint8List otherStream; + late Uint8List shortEncrypted; + setUpAll(() async { + encrypted = await crypto.encrypt(plaintext: plaintext, password: _password); + otherStream = await crypto.encrypt( + plaintext: plaintext, + password: _password, + ); + shortEncrypted = await crypto.encrypt( + plaintext: shortPlaintext, + password: _password, + ); + }); + + test( + 'T03 C02 C03 malformed envelope variants reject before sodium loads', + () async { + var loaded = 0; + final guarded = BackupCrypto( + sodiumLoader: () { + loaded++; + return loadSodiumForTest(); + }, + ); + final original = + jsonDecode(utf8.decode(_header(encrypted))) as Map; + final variants = { + 'magic changed': _flip(encrypted, 0), + 'magic truncated': Uint8List.sublistView(encrypted, 0, 7), + 'length truncated': Uint8List.sublistView(encrypted, 0, 11), + 'zero length': _setNumber(encrypted, 8, 0), + 'header cap plus one': _setNumber(encrypted, 8, 4097), + 'header cap invalid JSON': _replaceHeader( + encrypted, + List.filled(4096, 32), + ), + 'invalid UTF8': _replaceHeader(encrypted, [0xff]), + 'invalid escape': _replaceHeader( + encrypted, + utf8.encode(r'{"salt":"\q"}'), + ), + 'duplicate escaped key': _replaceHeader( + encrypted, + utf8.encode(r'{"salt":"a","\u0073alt":"b"}'), + ), + 'array header': _replaceHeader(encrypted, utf8.encode('[]')), + 'null header': _replaceHeader(encrypted, utf8.encode('null')), + }; + for (final field in original.keys) { + final absent = Map.from(original)..remove(field); + variants['missing $field'] = _replaceHeader( + encrypted, + utf8.encode(jsonEncode(absent)), + ); + final wrongType = Map.from(original)..[field] = false; + variants['wrong type $field'] = _replaceHeader( + encrypted, + utf8.encode(jsonEncode(wrongType)), + ); + } + for (final entry in { + 'formatVersion': 2, + 'suite': 'unsupported-suite', + 'opsLimit': 4, + 'memLimit': 1, + 'chunkSize': 1, + 'salt': base64UrlEncode(Uint8List(15)), + }.entries) { + final changed = Map.from(original) + ..[entry.key] = entry.value; + variants['unsupported ${entry.key}'] = _replaceHeader( + encrypted, + utf8.encode(jsonEncode(changed)), + ); + } + variants['salt invalid base64'] = _replaceHeader( + encrypted, + utf8.encode(jsonEncode({...original, 'salt': '!'})), + ); + for (final entry in variants.entries) { + await expectLater( + guarded.decrypt(container: entry.value, password: _password), + throwsA(isA()), + reason: entry.key, + ); + expect(loaded, 0, reason: entry.key); + } + expect( + await guarded.decrypt(container: shortEncrypted, password: _password), + shortPlaintext, + ); + expect(loaded, 1); + }, + ); + + test( + 'T03 C04 supported header bytes salt and stream header require actual authentication', + () async { + var loaded = 0; + final actual = BackupCrypto( + sodiumLoader: () { + loaded++; + return loadSodiumForTest(); + }, + ); + final original = + jsonDecode(utf8.decode(_header(shortEncrypted))) + as Map; + final salt = base64Url.decode(original['salt'] as String)..[0] ^= 1; + final originalFrames = fixture.frames(shortEncrypted); + final variants = { + // Identical supported metadata with different serialized bytes proves AAD + // binding without depending on a rejected algorithm or unsafe KDF value. + 'same metadata changed whitespace': _replaceHeader( + shortEncrypted, + utf8.encode(' ${utf8.decode(_header(shortEncrypted))}'), + ), + 'supported salt changed': _replaceHeader( + shortEncrypted, + utf8.encode(jsonEncode({...original, 'salt': base64UrlEncode(salt)})), + ), + 'same length stream header changed': fixture.withFrames( + shortEncrypted, + [_flip(originalFrames.first, 0), ...originalFrames.skip(1)], + ), + }; + for (final entry in variants.entries) { + final before = loaded; + await expectLater( + actual.decrypt(container: entry.value, password: _password), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.authentication, + ), + ), + reason: entry.key, + ); + expect(loaded, before + 1, reason: entry.key); + } + expect( + await actual.decrypt(container: shortEncrypted, password: _password), + shortPlaintext, + ); + }, + ); + + test( + 'T03 C05 frame count admission precedes KDF and structural frame failures never complete', + () async { + var loaded = 0; + final guarded = BackupCrypto( + sodiumLoader: () { + loaded++; + return loadSodiumForTest(); + }, + ); + final countAt = 12 + _headerLength(shortEncrypted); + for (final count in [0, 1, 100001]) { + await expectLater( + guarded.decrypt( + container: _setNumber(shortEncrypted, countAt, count), + password: _password, + ), + throwsA(isA()), + reason: 'count $count', + ); + expect(loaded, 0); + } + final variants = { + 'count larger than actual': _setNumber(shortEncrypted, countAt, 3), + 'zero frame length': _setNumber(shortEncrypted, countAt + 4, 0), + 'oversize frame length': _setNumber( + shortEncrypted, + countAt + 4, + 65536 + 1024 + 1, + ), + 'frame length prefix truncated': Uint8List.sublistView( + shortEncrypted, + 0, + countAt + 7, + ), + 'header frame truncated': Uint8List.sublistView( + shortEncrypted, + 0, + countAt + 9, + ), + }; + for (final entry in variants.entries) { + await expectLater( + guarded.decrypt(container: entry.value, password: _password), + throwsA(isA()), + reason: entry.key, + ); + } + expect( + await guarded.decrypt(container: shortEncrypted, password: _password), + shortPlaintext, + ); + }, + ); + + test( + 'T03 C06 first middle last tamper reordering duplication omission and foreign frames reject', + () async { + final original = fixture.frames(encrypted); + expect(original.length, 4); // stream header plus three real data frames + final variants = >{ + for (var i = 1; i <= 3; i++) + 'ciphertext frame $i changed': [ + for (var n = 0; n < original.length; n++) + n == i ? _flip(original[n], 0) : original[n], + ], + 'swap first and middle': [ + original[0], + original[2], + original[1], + original[3], + ], + 'duplicate first data frame': [ + original[0], + original[1], + original[1], + original[2], + original[3], + ], + 'omit middle': [original[0], original[1], original[3]], + 'foreign middle frame': [ + original[0], + original[1], + fixture.frames(otherStream)[2], + original[3], + ], + }; + for (final entry in variants.entries) { + await expectLater( + crypto.decrypt( + container: fixture.withFrames(encrypted, entry.value), + password: _password, + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.authentication, + ), + ), + reason: entry.key, + ); + } + expect( + await crypto.decrypt(container: encrypted, password: _password), + plaintext, + ); + }, + ); + + test( + 'T03 C07 C08 authenticated early final cannot hide extra frames or concatenated containers', + () async { + final shortFrames = fixture.frames(shortEncrypted); + final multi = fixture.frames(encrypted); + final variants = { + // Both first frames are genuinely authenticated, including finalPush; + // the appended frame is not a fabricated change to the final tag byte. + 'valid final followed by duplicate final frame': fixture.withFrames( + shortEncrypted, + [...shortFrames, shortFrames.last], + ), + 'valid final followed by another stream frame': fixture.withFrames( + shortEncrypted, + [...shortFrames, multi[1]], + ), + 'authenticated message frames but no final': fixture.withFrames( + encrypted, + multi.sublist(0, multi.length - 1), + ), + 'two complete containers': Uint8List.fromList([ + ...shortEncrypted, + ...encrypted, + ]), + 'truncated final frame': Uint8List.sublistView( + shortEncrypted, + 0, + shortEncrypted.length - 1, + ), + }; + for (final entry in variants.entries) { + await expectLater( + crypto.decrypt(container: entry.value, password: _password), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.authentication, + ), + ), + reason: entry.key, + ); + } + expect( + await crypto.decrypt(container: shortEncrypted, password: _password), + shortPlaintext, + ); + }, + ); + + test( + 'T03 C10 one byte provider chunks preserve authenticated bytes and reject a split malformed header', + () async { + Stream> split(Uint8List source, int size) async* { + for (var at = 0; at < source.length; at += size) { + yield Uint8List.sublistView( + source, + at, + (at + size).clamp(0, source.length), + ); + } + } + + for (final size in [1, 3, 17]) { + expect( + await fixture.collect( + crypto.decryptStream( + container: split(shortEncrypted, size), + password: _password, + ), + ), + shortPlaintext, + reason: 'provider chunk $size', + ); + } + await expectLater( + fixture.collect( + crypto.decryptStream( + container: split(_replaceHeader(shortEncrypted, [0xff]), 1), + password: _password, + ), + ), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/core/backup/backup_crypto_stream_test.dart b/test/core/backup/backup_crypto_stream_test.dart new file mode 100644 index 00000000..61d79c9d --- /dev/null +++ b/test/core/backup/backup_crypto_stream_test.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import '../../helpers/sodium_test_loader.dart'; + +const password = 'portable stream backup'; + +class _UnreadableBytes extends ListBase { + _UnreadableBytes(this.length); + @override + int length; + @override + int operator [](int index) => + throw StateError('must reject before payload read'); + @override + void operator []=(int index, int value) => + throw UnsupportedError('immutable'); +} + +Future collect(Stream> stream) async { + final out = BytesBuilder(copy: false); + await for (final chunk in stream) { + out.add(chunk); + } + return out.takeBytes(); +} + +List frames(Uint8List data) { + final header = ByteData.sublistView(data, 8, 12).getUint32(0); + var at = 12 + header; + final count = ByteData.sublistView(data, at, at + 4).getUint32(0); + at += 4; + final result = []; + for (var i = 0; i < count; i++) { + final len = ByteData.sublistView(data, at, at + 4).getUint32(0); + at += 4; + result.add(Uint8List.sublistView(data, at, at + len)); + at += len; + } + return result; +} + +Uint8List withFrames(Uint8List data, List frames) { + final header = ByteData.sublistView(data, 8, 12).getUint32(0); + final result = BytesBuilder(copy: false) + ..add(Uint8List.sublistView(data, 0, 12 + header)); + void number(int n) => + result.add((ByteData(4)..setUint32(0, n)).buffer.asUint8List()); + number(frames.length); + for (final frame in frames) { + number(frame.length); + result.add(frame); + } + return result.takeBytes(); +} + +void main() { + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + for (final length in [0, 1, 65535, 65536, 65537, 131077]) { + test( + 'crypto1 exact frame count and round trip at $length plaintext bytes', + () async { + final source = Uint8List.fromList( + List.generate(length, (i) => i % 251), + ); + Stream> split() async* { + for (var i = 0; i < length; i += 1031) { + yield Uint8List.sublistView(source, i, (i + 1031).clamp(0, length)); + } + } + + final encrypted = await collect( + crypto.encryptStream( + plaintext: split(), + plaintextLength: length, + password: password, + ), + ); + expect( + frames(encrypted).length, + 1 + (length == 0 ? 1 : (length + 65535) ~/ 65536), + ); + final restored = await collect( + crypto.decryptStream( + container: Stream.value(encrypted), + password: password, + ), + ); + expect(restored, source); + }, + ); + } + test( + 'wrong declared plaintext length never emits a valid final container', + () async { + await expectLater( + collect( + crypto.encryptStream( + plaintext: Stream.value([1, 2]), + plaintextLength: 1, + password: password, + ), + ), + throwsFormatException, + ); + await expectLater( + collect( + crypto.encryptStream( + plaintext: Stream.value([1]), + plaintextLength: 2, + password: password, + ), + ), + throwsFormatException, + ); + }, + ); + test( + 'removed final frame and invalid secretstream header are typed authentication', + () async { + final data = await crypto.encrypt( + plaintext: Uint8List(65537), + password: password, + ); + final original = frames(data); + for (final variant in [ + original.sublist(0, original.length - 1), + [Uint8List(3), ...original.skip(1)], + ]) { + await expectLater( + crypto.decrypt( + container: withFrames(data, variant), + password: password, + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.authentication, + ), + ), + ); + } + }, + ); + test('trailing ciphertext is refused after final authentication', () async { + final data = await crypto.encrypt( + plaintext: Uint8List(8), + password: password, + ); + final tail = Uint8List.fromList([...data, 0]); + await expectLater( + crypto.decrypt(container: tail, password: password), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.authentication, + ), + ), + ); + }); + test( + 'actual reader close failure preserves the preceding rejection', + () async { + final data = await crypto.encrypt( + plaintext: Uint8List(8), + password: password, + ); + final source = StreamController>( + onCancel: () => Future.error(StateError('injected close failure')), + ); + source.add(Uint8List.fromList([...data, 0])); + await expectLater( + collect( + crypto.decryptStream(container: source.stream, password: password), + ), + throwsA( + isA().having( + (e) => (e.originalError as BackupProcessingFailure).kind, + 'original', + BackupFailureKind.authentication, + ), + ), + ); + }, + ); + test( + 'duplicate and excessively nested envelope headers reject before KDF', + () async { + var loaded = 0; + final guarded = BackupCrypto( + sodiumLoader: () { + loaded++; + return loadSodiumForTest(); + }, + ); + for (final header in [ + '{"formatVersion":1,"formatVersion":1}', + '${'[' * 33}0${']' * 33}', + ]) { + final bytes = utf8.encode(header); + final input = Uint8List.fromList([ + ...ascii.encode('ONTIMEBK'), + ...(ByteData(4)..setUint32(0, bytes.length)).buffer.asUint8List(), + ...bytes, + ]); + await expectLater( + guarded.decrypt(container: input, password: password), + throwsA(isA()), + ); + } + expect(loaded, 0); + }, + ); + test( + 'whole-input capacity rejection occurs before KDF or payload indexing', + () async { + var loaded = 0; + final guarded = BackupCrypto( + sodiumLoader: () { + loaded++; + return loadSodiumForTest(); + }, + ); + await expectLater( + collect( + guarded.decryptStream( + container: Stream.value( + _UnreadableBytes(BackupLimits.cipherBytes + 1), + ), + password: password, + ), + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + await expectLater( + collect( + guarded.encryptStream( + plaintext: const Stream.empty(), + plaintextLength: BackupLimits.plainBytes + 1, + password: password, + ), + ), + throwsA(isA()), + ); + expect(loaded, 0); + }, + ); +} diff --git a/test/core/backup/backup_export_snapshot_test.dart b/test/core/backup/backup_export_snapshot_test.dart new file mode 100644 index 00000000..b664d89f --- /dev/null +++ b/test/core/backup/backup_export_snapshot_test.dart @@ -0,0 +1,449 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_content.dart'; +import 'package:on_time_front/core/backup/backup_export_snapshot.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +// Explicit ordinary SQLite workflow fixtures, not encryption/provider evidence. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late AppDatabase source; + final cutoff = DateTime.utc(2026, 9, 24, 12); + setUp(() async { + source = AppDatabase.forTesting(NativeDatabase.memory()); + await source.customStatement( + 'INSERT INTO users ' + '(id,spare_time,note,data_revision,is_onboarding_completed,store_incarnation) ' + "VALUES ('local-profile',7,'before',9,1,'SOURCE-INSTALLATION-IDENTITY')", + ); + await source.customStatement( + "INSERT INTO places(id,place_name) VALUES ('place','Library')", + ); + await source.customStatement( + 'INSERT INTO schedules ' + '(id,place_id,schedule_name,time_zone_id,schedule_time,move_time,occurrence_offset_seconds,' + 'is_started,aggregate_incarnation,last_mutation_id,requires_start_confirmation) ' + "VALUES ('schedule','place','Read','Asia/Seoul','2027-03-28T02:30:00.000',600000,32400," + "1,'SOURCE-AGGREGATE','SOURCE-MUTATION',1)", + ); + }); + tearDown(() => source.close()); + + Future memoryStage() async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final stage = RestoreStaging(db, db.close); + addTearDown(stage.release); + return stage; + } + + Future capture({ + BackupBudget? budget, + RestoreStagingFactory? factory, + }) async { + final snapshot = await BackupExportSnapshot.create( + source, + cutoff: cutoff, + sourceAppVersion: '1.0+7', + sourcePlatform: 'android', + budget: budget ?? BackupBudget(), + stagingFactory: factory ?? memoryStage, + ); + addTearDown(snapshot.release); + return snapshot; + } + + Future> bytes(BackupExportSnapshot snapshot) => + snapshot.plaintext().expand((chunk) => chunk).toList(); + + test( + 'two passes freeze revision/content while the active store changes', + () async { + final snapshot = await capture(); + final first = await bytes(snapshot); + await source.customStatement( + "UPDATE users SET note='after',data_revision=10", + ); + await source.customStatement( + "UPDATE schedules SET schedule_name='Edited'", + ); + final second = await bytes(snapshot); + expect(second, first); + final json = jsonDecode(utf8.decode(first)) as Map; + expect(json['dataRevision'], 9); + expect(json['cutoff'], cutoff.toIso8601String()); + expect(json['profile']['note'], 'before'); + expect(json['schedules'].single['name'], 'Read'); + expect(json['schedules'].single['moveTimeMinutes'], 10); + expect(json['schedules'].single['spareTimeMinutes'], isNull); + expect(json['schedules'].single['civilTime'], '2027-03-28T02:30:00.000'); + final decoded = BackupContent.fromJson(json); + expect(decoded.schedules.single.scheduleTime.hour, 2); + expect(decoded.schedules.single.occurrenceOffsetSeconds, 32400); + expect(snapshot.dataRevision, 9); + expect( + (await source + .customSelect('SELECT note,data_revision FROM users') + .getSingle()) + .data, + {'note': 'after', 'data_revision': 10}, + ); + }, + ); + + test( + 'SQL duration milliseconds become portable minutes with legacy truncation', + () async { + await source.customStatement( + 'UPDATE schedules SET move_time=1200000,schedule_spare_time=59999', + ); + final json = + jsonDecode(utf8.decode(await bytes(await capture()))) + as Map; + final schedule = json['schedules'].single; + expect(schedule['moveTimeMinutes'], 20); + expect(schedule['spareTimeMinutes'], 0); + expect( + BackupContent.fromJson(json).schedules.single.moveTime, + const Duration(minutes: 20), + ); + }, + ); + + test( + 'negative sub-minute SQL durations cannot truncate into valid zero', + () async { + for (final column in ['move_time', 'schedule_spare_time']) { + await source.customStatement( + 'UPDATE schedules SET move_time=0,schedule_spare_time=NULL', + ); + await source.customStatement('UPDATE schedules SET $column=-1'); + final snapshot = await capture(); + await expectLater( + bytes(snapshot), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.dataInvariant, + ), + ), + ); + await snapshot.release(); + expect( + (await source + .customSelect('SELECT $column AS value FROM schedules') + .getSingle()) + .read('value'), + -1, + ); + } + }, + ); + + test( + 'private snapshot is query-only and installation/runtime values never enter the payload', + () async { + late AppDatabase owned; + final snapshot = await capture( + factory: () async { + final stage = await memoryStage(); + owned = stage.database; + return stage; + }, + ); + await expectLater( + owned.customStatement("UPDATE users SET note='mutated'"), + throwsA(isA().having((e) => e.resultCode, 'code', 8)), + ); + final text = utf8.decode(await bytes(snapshot)); + for (final value in [ + 'SOURCE-INSTALLATION-IDENTITY', + 'SOURCE-AGGREGATE', + 'SOURCE-MUTATION', + 'requiresStartConfirmation', + 'isStarted', + 'aggregateIncarnation', + 'storeIncarnation', + ]) { + expect(text, isNot(contains(value))); + } + final state = + (await owned + .customSelect( + 'SELECT is_started,requires_start_confirmation FROM schedules', + ) + .getSingle()) + .data; + expect(state, {'is_started': 0, 'requires_start_confirmation': 0}); + }, + ); + + test( + 'keyset copy includes negative/zero rowids and crosses bounded pages without loss', + () async { + await source.transaction(() async { + for (final rowid in [-9, 0, ...List.generate(65, (i) => i + 10)]) { + await source.customStatement( + 'INSERT INTO schedules ' + '(rowid,id,place_id,schedule_name,schedule_time,move_time) VALUES (?,?,?,?,?,?)', + [ + rowid, + 's$rowid', + 'place', + 'Page $rowid', + '2027-01-01T12:00:00.000', + 0, + ], + ); + } + }); + final json = + jsonDecode(utf8.decode(await bytes(await capture()))) + as Map; + final ids = (json['schedules'] as List).map((s) => s['id']).toList(); + expect(ids.length, 68); + expect(ids.toSet().length, 68); + expect(ids, containsAll(['s-9', 's0', 's74', 'schedule'])); + expect((json['schedulePreparations'] as Map).keys.toSet(), ids.toSet()); + }, + ); + + test( + 'raw forward preparation links and original template timestamps survive copying', + () async { + await source.transaction(() async { + await source.customStatement('PRAGMA defer_foreign_keys = ON'); + await source.customStatement( + 'INSERT INTO preparation_users ' + '(id,user_id,preparation_name,preparation_time,next_preparation_id) VALUES (?,?,?,?,?)', + ['head', 'local-profile', '준비', 0, 'tail'], + ); + await source.customStatement( + 'INSERT INTO preparation_users ' + '(id,user_id,preparation_name,preparation_time) VALUES (?,?,?,?)', + ['tail', 'local-profile', '마지막', 2000], + ); + await source.customStatement( + 'INSERT INTO preparation_templates ' + '(id,template_name,created_at,updated_at) VALUES (?,?,?,?)', + ['template', 'Template', 1700000000, 1700000600], + ); + for (final position in [1, 0]) { + await source.customStatement( + 'INSERT INTO preparation_template_steps ' + '(id,template_id,preparation_name,preparation_time,position) VALUES (?,?,?,?,?)', + ['t$position', 'template', 'Step $position', position, position], + ); + } + }); + final json = + jsonDecode(utf8.decode(await bytes(await capture()))) + as Map; + expect(json['defaultPreparation'], [ + {'id': 'head', 'name': '준비', 'minutes': 0, 'nextId': 'tail'}, + {'id': 'tail', 'name': '마지막', 'minutes': 2000, 'nextId': null}, + ]); + final template = json['templates'].single; + expect(template['createdAt'], '2023-11-14T22:13:20.000Z'); + expect(template['updatedAt'], '2023-11-14T22:23:20.000Z'); + expect(template['preparation'], [ + {'id': 't0', 'name': 'Step 0', 'minutes': 0, 'nextId': 't1'}, + {'id': 't1', 'name': 'Step 1', 'minutes': 1, 'nextId': null}, + ]); + expect( + BackupContent.fromJson(json).defaultPreparation.totalDuration.inMinutes, + 2000, + ); + }, + ); + + test( + 'UTF-8 output pieces stay bounded through escape and surrogate boundaries', + () async { + final note = '${'가' * 3000}${'😀' * 2000}\n\\\u0000'; + await source.customStatement('UPDATE users SET note=?', [note]); + final snapshot = await capture(); + final pieces = await snapshot.plaintext().toList(); + expect(pieces.every((p) => p.length <= 16384), isTrue); + expect(pieces.length, greaterThan(1)); + expect( + pieces.take(pieces.length - 1).every((p) => p.length == 16384), + isTrue, + ); + final json = + jsonDecode(utf8.decode(pieces.expand((p) => p).toList())) + as Map; + expect(json['profile']['note'], note); + }, + ); + + test( + 'oversized source text fails and cleans the candidate without changing the source', + () async { + // Construct the large value inside SQLite, not a giant Dart source buffer. + await source.customStatement( + "UPDATE users SET note=replace(hex(zeroblob(32769)),'0','x')", + ); + var releases = 0; + await expectLater( + capture( + factory: () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(db, () async { + releases++; + await db.close(); + }); + }, + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(releases, 1); + expect( + (await source + .customSelect('SELECT length(note) AS size FROM users') + .getSingle()) + .read('size'), + 65538, + ); + }, + ); + + test( + 'non-text source blob is rejected and a late copy failure rolls back target rows', + () async { + await source.customStatement( + 'UPDATE schedules SET schedule_note=zeroblob(1048576)', + ); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await expectLater( + source.transaction( + () => copyPortableBackupRows(source, target, budget: BackupBudget()), + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.dataInvariant, + ), + ), + ); + expect( + (await target + .customSelect('SELECT count(*) AS n FROM users') + .getSingle()) + .read('n'), + 0, + ); + expect( + (await target + .customSelect('SELECT count(*) AS n FROM places') + .getSingle()) + .read('n'), + 0, + ); + expect( + (await source + .customSelect( + 'SELECT length(schedule_note) AS n FROM schedules', + ) + .getSingle()) + .read('n'), + 1048576, + ); + }, + ); + + test( + 'copy refuses an occupied destination without deleting or replacing it', + () async { + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await target.customStatement( + "INSERT INTO places(id,place_name) VALUES ('keep','Original')", + ); + await expectLater( + copyPortableBackupRows(source, target, budget: BackupBudget()), + throwsStateError, + ); + expect( + (await target + .customSelect('SELECT id,place_name FROM places') + .getSingle()) + .data, + {'id': 'keep', 'place_name': 'Original'}, + ); + }, + ); + + test( + 'cancelled encoder terminates before release and cannot start another pass', + () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire(); + await source.customStatement('UPDATE users SET note=?', ['가' * 10000]); + final snapshot = await capture(budget: BackupBudget(lease: lease)); + final reader = StreamIterator(snapshot.plaintext()); + expect(await reader.moveNext(), isTrue); + await expectLater(snapshot.release(), throwsStateError); + lease.requestCancellation(); + await expectLater( + reader.moveNext(), + throwsA(isA()), + ); + await reader.cancel(); + await snapshot.release(); + lease.release(); + await expectLater(snapshot.plaintext().drain(), throwsStateError); + expect(owner.active, isNull); + }, + ); + + test( + 'failed creation retains same-owner cleanup retry and the original rejection', + () async { + await source.customStatement( + "UPDATE users SET note=replace(hex(zeroblob(32769)),'0','x')", + ); + final owner = BackupProcessingOwner(); + final lease = owner.acquire(); + var closes = 0; + late AppDatabase stageDb; + await expectLater( + capture( + budget: BackupBudget(lease: lease), + factory: () async { + stageDb = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(stageDb, () async { + if (++closes == 1) throw StateError('injected close failure'); + await stageDb.close(); + }); + }, + ), + throwsA( + isA().having( + (e) => (e.originalError as BackupProcessingFailure).kind, + 'original', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(owner.active, same(lease)); + expect(lease.phase, BackupProcessingPhase.cleanupPending); + await owner.retryCleanup(); + expect(closes, 2); + expect(owner.active, isNull); + }, + ); +} diff --git a/test/core/backup/backup_failure_retry_acceptance_test.dart b/test/core/backup/backup_failure_retry_acceptance_test.dart new file mode 100644 index 00000000..b23d337c --- /dev/null +++ b/test/core/backup/backup_failure_retry_acceptance_test.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_crypto_stream_test.dart' as fixture; +import 'backup_validation_contract_test.dart' show validBackup; + +// Contract: an unsuccessful preview cannot mutate the active data or runtime, +// and must release its resource lease so the same service can retry normally. +// Memory staging is explicit: this is not encrypted staging or OS evidence. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const password = 'Synthetic T03 service password'; + const sensitiveNote = 'Synthetic private sentinel never shown in failure'; + late AppDatabase database; + late BackupService service; + late LocalDataOperationGate gate; + late BackupProcessingOwner processing; + late RestoreRuntimeIdentity runtime; + late _ObservedCleanup cleanup; + late BackupCrypto crypto; + late Uint8List valid; + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'early_start_session_sentinel': 'synthetic runtime retained', + }); + database = AppDatabase.forTesting(NativeDatabase.memory()); + gate = LocalDataOperationGate(); + processing = BackupProcessingOwner(); + runtime = RestoreRuntimeIdentity(); + cleanup = _ObservedCleanup(); + crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 7), + note: sensitiveNote, + eligibleOutcomeCount: 4, + onTimeOutcomeCount: 3, + isOnboardingCompleted: true, + ), + ); + await database.preparationUserDao.createPreparationUser( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'active-step', + preparationName: 'Synthetic active step', + preparationTime: Duration(minutes: 5), + ), + ], + ), + 'local-profile', + ); + await database.scheduleDao.createSchedule( + ScheduleEntity( + id: 'active-schedule', + place: const PlaceEntity( + id: 'active-place', + placeName: 'Synthetic active place', + ), + scheduleName: 'Synthetic active schedule', + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + scheduleTime: DateTime(2026, 9, 2, 10), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: sensitiveNote, + ).toScheduleWithPlaceRow(), + ); + await runtime.load(database); + service = BackupService( + database, + _Metadata(), + cleanup, + crypto: crypto, + ingestionFactory: memoryBackupIngestion, + processingOwner: processing, + stagingFactory: memoryRestoreStaging, + runtimeIdentity: runtime, + cleanupPlatform: noPlatformRestoreCleanup, + operationGate: gate, + ); + valid = await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(validBackup()))), + password: password, + ); + }); + tearDown(() => database.close()); + + test( + 'T03 C12 failure classes preserve active rows and same-service preview retry succeeds', + () async { + final before = await _logicalRows(database); + final generation = gate.generation; + final identity = runtime.storeIncarnation; + final rejectLegacy = runtime.rejectLegacy; + final pending = runtime.pending; + final prefs = await SharedPreferences.getInstance(); + final prefsBefore = { + for (final key in prefs.getKeys()) key: prefs.get(key), + }; + Future encode(Object input) => crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(input))), + password: password, + ); + final unsupported = validBackup()..['formatVersion'] = 999; + final invalidGraph = validBackup(); + invalidGraph['defaultPreparation'] = [ + {'id': 'a', 'name': 'Synthetic step', 'minutes': 1, 'nextId': 'a'}, + ]; + final frames = fixture.frames(valid); + final tampered = Uint8List.fromList(valid)..[valid.length - 1] ^= 1; + final variants = <(String, Uint8List, String)>[ + ('wrong password', valid, 'Synthetic wrong password'), + ('ciphertext authentication', tampered, password), + ( + 'missing final', + fixture.withFrames(valid, frames.sublist(0, frames.length - 1)), + password, + ), + ('trailing ciphertext', Uint8List.fromList([...valid, 0]), password), + ( + 'authenticated invalid JSON', + await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode('{')), + password: password, + ), + password, + ), + ('unsupported payload version', await encode(unsupported), password), + ('invalid preparation graph', await encode(invalidGraph), password), + ]; + for (final (label, bytes, suppliedPassword) in variants) { + Object? failure; + try { + final unexpected = await service.previewEncryptedBackup( + bytes, + suppliedPassword, + ); + await unexpected.dispose(); + fail('$label unexpectedly produced a preview'); + } on BackupProcessingFailure catch (error) { + failure = error; + } + expect(failure, isA(), reason: label); + for (final secret in [password, suppliedPassword, sensitiveNote]) { + expect(failure.toString(), isNot(contains(secret)), reason: label); + } + expect(processing.active, isNull, reason: label); + expect(gate.isAvailable, isTrue, reason: label); + expect(gate.generation, generation, reason: label); + expect(await _logicalRows(database), before, reason: label); + expect(runtime.storeIncarnation, identity, reason: label); + expect(runtime.rejectLegacy, rejectLegacy, reason: label); + expect(runtime.pending, pending, reason: label); + expect(cleanup.calls, 0, reason: label); + await prefs.reload(); + expect( + {for (final key in prefs.getKeys()) key: prefs.get(key)}, + prefsBefore, + reason: label, + ); + final candidate = await service.previewEncryptedBackup(valid, password); + expect( + processing.active?.phase, + BackupProcessingPhase.preview, + reason: label, + ); + expect(candidate.preview.scheduleCount, 1, reason: label); + await candidate.dispose(); + expect(processing.active, isNull, reason: label); + expect( + await _logicalRows(database), + before, + reason: '$label retry is preview only', + ); + expect(gate.generation, generation, reason: label); + expect(cleanup.calls, 0, reason: label); + } + }, + ); +} + +Future>>> _logicalRows( + AppDatabase db, +) async { + final result = >>{}; + for (final table in db.allTables) { + final name = table.actualTableName; + final rows = await db + .customSelect('SELECT * FROM "$name" ORDER BY rowid') + .get(); + result[name] = rows + .map((row) => Map.from(row.data)) + .toList(); + } + return result; +} + +class _ObservedCleanup extends NoopAlarmCleanup { + int calls = 0; + @override + Future call() async { + calls++; + } + + @override + Future forDataReplacement() async { + calls++; + } +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1.0.1', buildNumber: '1'); +} diff --git a/test/core/backup/backup_file_export_port_test.dart b/test/core/backup/backup_file_export_port_test.dart index 7e249284..13c175c4 100644 --- a/test/core/backup/backup_file_export_port_test.dart +++ b/test/core/backup/backup_file_export_port_test.dart @@ -1,65 +1,256 @@ +import 'dart:async'; import 'dart:typed_data'; - +import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/backup/backup_file_export_port.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; const port = NativeBackupFileExportPort(); + late BackupProcessingOwner owner; + late BackupProcessingLease lease; + late List calls; + late BytesBuilder file; + var outcome = 'saved'; + var cleanupPending = false; + Future native(MethodCall call) async { + calls.add(call); + final args = call.arguments as Map; + switch (call.method) { + case 'begin': + expect(args, {'suggestedName': 'test.ontimebackup'}); + return 'opaque-attempt'; + case 'append': + expect(args['handle'], 'opaque-attempt'); + expect( + args['sequence'], + calls.where((c) => c.method == 'append').length - 1, + ); + final bytes = args['bytes'] as Uint8List; + expect(bytes.length, inInclusiveRange(1, 65536)); + file.add(bytes); + return true; + case 'seal': + final bytes = file.toBytes(); + expect(args['length'], bytes.length); + expect(args['sha256'], sha256.convert(bytes).toString()); + return true; + case 'export': + case 'cancel': + return {'outcome': outcome, 'cleanupUnconfirmed': cleanupPending}; + default: + fail('unexpected method'); + } + } - tearDown( - () => messenger.setMockMethodCallHandler( + Future run(Stream> input) => + port.exportStream( + encrypted: input, + suggestedName: 'test.ontimebackup', + lease: lease, + ); + setUp(() { + owner = BackupProcessingOwner(); + lease = owner.acquire(); + calls = []; + file = BytesBuilder(copy: false); + outcome = 'saved'; + cleanupPending = false; + messenger.setMockMethodCallHandler( + NativeBackupFileExportPort.channel, + native, + ); + }); + tearDown(() { + messenger.setMockMethodCallHandler( NativeBackupFileExportPort.channel, null, - ), + ); + }); + + test( + 'opaque handle with strict bounded acknowledged chunks and independent seal digest', + () async { + final input = Uint8List.fromList(List.generate(131091, (i) => i % 251)); + expect(await run(Stream.value(input)), BackupFileExportReceipt.saved); + expect(file.toBytes(), input); + expect(calls.map((c) => c.method), [ + 'begin', + 'append', + 'append', + 'append', + 'seal', + 'export', + ]); + expect( + calls + .where((c) => c.method == 'append') + .map((c) => (c.arguments['bytes'] as Uint8List).length), + [65536, 65536, 19], + ); + }, ); + test('append awaits ack before requesting another source chunk', () async { + final ack = Completer(); + var produced = 0; + messenger.setMockMethodCallHandler(NativeBackupFileExportPort.channel, ( + call, + ) async { + if (call.method == 'append' && produced == 1) { + await ack.future; + } + return native(call); + }); + Stream> input() async* { + produced++; + yield [1]; + produced++; + yield [2]; + } + final pending = run(input()); + await Future.delayed(const Duration(milliseconds: 10)); + expect(produced, 1); + ack.complete(true); + expect(await pending, BackupFileExportReceipt.saved); + expect(produced, 2); + }); + test('cancel receipt never marks saved', () async { + outcome = 'cancelled'; + expect(await run(Stream.value([1])), BackupFileExportReceipt.cancelled); + }); test( - 'sends encrypted bytes and name only; saved receipt has no location', + 'source failure cannot seal or open picker and closes same owner', + () async { + outcome = 'failed'; + await expectLater( + run( + Stream.error( + const BackupProcessingFailure( + BackupFailureKind.resourceLimit, + 'plainBytes', + ), + ), + ), + throwsA(isA()), + ); + expect(calls.map((c) => c.method), ['begin', 'cancel']); + }, + ); + test( + 'platform resource failure retains typed kind without provider path', () async { messenger.setMockMethodCallHandler(NativeBackupFileExportPort.channel, ( call, ) async { - expect(call.method, 'export'); - expect(call.arguments, { - 'encryptedBytes': [1, 2, 3], - 'suggestedName': 'test.ontimebackup', - }); - return 'saved'; + if (call.method == 'append') { + throw PlatformException( + code: 'export_limit', + message: '/private/provider', + ); + } + return native(call); }); - expect( - await port.export( - encryptedBytes: Uint8List.fromList([1, 2, 3]), - suggestedName: 'test.ontimebackup', + outcome = 'failed'; + await expectLater( + run(Stream.value([1])), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), ), - BackupFileExportReceipt.saved, ); + expect(calls.last.method, 'cancel'); }, ); - - test('cancel is distinct from success', () async { - messenger.setMockMethodCallHandler( - NativeBackupFileExportPort.channel, - (_) async => 'cancelled', - ); - expect( - await port.export(encryptedBytes: Uint8List(1), suggestedName: 'test'), - BackupFileExportReceipt.cancelled, + test( + 'saved remains saved when cleanup is unconfirmed and retry keeps ownership', + () async { + cleanupPending = true; + expect(await run(Stream.value([1])), BackupFileExportReceipt.saved); + expect(owner.active, same(lease)); + expect(lease.phase, BackupProcessingPhase.cleanupPending); + cleanupPending = false; + await owner.retryCleanup(); + expect(owner.active, isNull); + }, + ); + test('failure plus cleanup failure is retained and actionable', () async { + outcome = 'failed'; + var closes = 0; + messenger.setMockMethodCallHandler(NativeBackupFileExportPort.channel, ( + call, + ) async { + if (call.method == 'cancel' && ++closes == 1) { + throw PlatformException(code: 'export_io'); + } + return native(call); + }); + await expectLater( + run( + Stream.error( + const BackupProcessingFailure(BackupFailureKind.userCancelled), + ), + ), + throwsA(isA()), ); + expect(owner.active, same(lease)); + await owner.retryCleanup(); + expect(owner.active, isNull); + expect(closes, 2); }); - - for (final response in [null, '', 'unknown']) { - test('unrecognized native response $response is never success', () async { - messenger.setMockMethodCallHandler( - NativeBackupFileExportPort.channel, - (_) async => response, - ); + test( + 'cancel during append waits for append return and authoritative saved wins', + () async { + final append = Completer(); + final entered = Completer(); + messenger.setMockMethodCallHandler(NativeBackupFileExportPort.channel, ( + call, + ) async { + if (call.method == 'append') { + entered.complete(); + await append.future; + } + return native(call); + }); + var complete = false; + final pending = run(Stream.value([1])).then((v) { + complete = true; + return v; + }); + await entered.future; + lease.requestCancellation(); + await Future.delayed(Duration.zero); + expect(complete, false); + append.complete(true); + expect(await pending, BackupFileExportReceipt.saved); + expect(calls.any((c) => c.method == 'export'), false); + }, + ); + for (final invalid in [ + null, + 'saved', + {'outcome': 'unknown', 'cleanupUnconfirmed': false}, + ]) { + test('invalid receipt $invalid is not success', () async { + messenger.setMockMethodCallHandler(NativeBackupFileExportPort.channel, ( + call, + ) async { + if (call.method == 'export') return invalid; + if (call.method == 'cancel') { + return {'outcome': 'failed', 'cleanupUnconfirmed': false}; + } + return native(call); + }); await expectLater( - port.export(encryptedBytes: Uint8List(1), suggestedName: 'test'), - throwsA(isA()), + run(Stream.value([1])), + throwsA(anyOf(isA(), isA())), ); }); } diff --git a/test/core/backup/backup_file_import_port_test.dart b/test/core/backup/backup_file_import_port_test.dart new file mode 100644 index 00000000..f03fd141 --- /dev/null +++ b/test/core/backup/backup_file_import_port_test.dart @@ -0,0 +1,221 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_file_import_port.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const port = NativeBackupFileImportPort(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = []; + late Future Function(MethodCall) handler; + setUp(() { + calls.clear(); + handler = (call) async => switch (call.method) { + 'begin' => 'attempt', + 'pick' => {'length': null}, + 'close' => true, + _ => throw StateError('Unexpected call'), + }; + messenger.setMockMethodCallHandler(NativeBackupFileImportPort.channel, ( + call, + ) { + calls.add(call); + return handler(call); + }); + }); + tearDown( + () => messenger.setMockMethodCallHandler( + NativeBackupFileImportPort.channel, + null, + ), + ); + + test( + 'unknown length and short reads continue until actual empty EOF', + () async { + var reads = 0; + final normal = handler; + handler = (call) async { + if (call.method != 'read') return normal(call); + reads++; + return { + 'bytes': reads == 1 ? Uint8List.fromList([1, 2]) : Uint8List(0), + 'eof': reads == 2, + }; + }; + final source = (await port.select())!; + expect(source.declaredLength, isNull); + expect(await source.openRead().expand((b) => b).toList(), [1, 2]); + expect(reads, 2); + expect(calls.where((c) => c.method == 'close').length, 1); + }, + ); + + test( + 'metadata over cap rejects before native read and confirms close', + () async { + final normal = handler; + handler = (call) async => call.method == 'pick' + ? {'length': BackupLimits.cipherBytes + 1} + : normal(call); + await expectLater( + port.select(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(calls.map((c) => c.method), ['begin', 'pick', 'close']); + }, + ); + + test('actual cap is accepted only after a one-byte EOF probe', () async { + final normal = handler; + var remaining = BackupLimits.cipherBytes; + final chunk = Uint8List(65536); + final requested = []; + handler = (call) async { + if (call.method != 'read') return normal(call); + final size = (call.arguments as Map)['maxBytes'] as int; + requested.add(size); + if (remaining == 0) return {'bytes': Uint8List(0), 'eof': true}; + remaining -= chunk.length; + return {'bytes': chunk, 'eof': false}; + }; + final source = (await port.select())!; + var count = 0; + await for (final bytes in source.openRead()) { + count += bytes.length; + } + expect(count, BackupLimits.cipherBytes); + expect(requested.last, 1); + expect(requested.every((n) => n <= 65536), isTrue); + }); + + test( + 'one byte after actual cap is resource failure even with underreported metadata', + () async { + final normal = handler; + var remaining = BackupLimits.cipherBytes; + final chunk = Uint8List(65536); + handler = (call) async { + if (call.method == 'pick') return {'length': 1}; + if (call.method != 'read') return normal(call); + if (remaining == 0) return {'bytes': Uint8List(1), 'eof': false}; + remaining -= chunk.length; + return {'bytes': chunk, 'eof': false}; + }; + final source = (await port.select())!; + await expectLater( + source.openRead().drain(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(calls.last.method, 'close'); + }, + ); + + test('zero progress is IO rather than early EOF', () async { + final normal = handler; + handler = (call) async => call.method == 'read' + ? {'bytes': Uint8List(0), 'eof': false} + : normal(call); + final source = (await port.select())!; + await expectLater( + source.openRead().drain(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.inputOutput, + ), + ), + ); + }); + + test( + 'cancel waits for close acknowledgement and preserves close failure for retry', + () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire(); + final selected = Completer(); + final closed = Completer(); + var closes = 0; + final normal = handler; + handler = (call) async { + if (call.method == 'pick') return selected.future; + if (call.method == 'close') { + closes++; + return closed.future; + } + return normal(call); + }; + final selection = port.select(lease: lease); + await Future.delayed(Duration.zero); + lease.requestCancellation(); + await Future.delayed(Duration.zero); + expect(owner.active, same(lease)); + expect(closes, 1); + selected.complete(null); + var completed = false; + final expected = expectLater( + selection, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.userCancelled, + ), + ), + ).then((_) => completed = true); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + closed.complete(true); + await expected; + lease.release(); + expect(owner.active, isNull); + }, + ); + + test( + 'original read failure and cleanup failure remain distinct, close retry works', + () async { + final normal = handler; + var closes = 0; + handler = (call) async { + if (call.method == 'read') { + throw PlatformException(code: 'import_limit'); + } + if (call.method == 'close' && closes++ == 0) { + throw PlatformException(code: 'import_io'); + } + return normal(call); + }; + final source = (await port.select())!; + await expectLater( + source.openRead().drain(), + throwsA( + isA().having( + (e) => (e.originalError as BackupProcessingFailure).kind, + 'original', + BackupFailureKind.resourceLimit, + ), + ), + ); + await source.close(); + expect(closes, 2); + }, + ); +} diff --git a/test/core/backup/backup_ingestion_store_test.dart b/test/core/backup/backup_ingestion_store_test.dart new file mode 100644 index 00000000..ea5b7c54 --- /dev/null +++ b/test/core/backup/backup_ingestion_store_test.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/open.dart'; +import 'package:sqlite3/sqlite3.dart' as sql; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (library != null) open.overrideForAll(() => DynamicLibrary.open(library)); + + test( + 'disk-shaped sink preserves forward order and skips unknown subtree reads', + () async { + final budget = BackupBudget(); + final store = BackupIngestionStore.memoryForTesting(budget); + addTearDown(store.release); + final root = await BackupJsonReader(store, budget).read( + Stream.value( + utf8.encode( + '{"schedules":[{"id":"later-reference"}],"unknown":{"huge":"ignored"},"version":1}', + ), + ), + ); + store.finish(); + expect(store.fields(root, {'version'}), {'version': 1}); + expect(store.children(root).map((r) => r['key']), [ + 'schedules', + 'unknown', + 'version', + ]); + }, + ); + + test( + 'unique index rejects escaped duplicate before last-value replacement', + () async { + final budget = BackupBudget(); + final store = BackupIngestionStore.memoryForTesting(budget); + addTearDown(store.release); + await expectLater( + BackupJsonReader( + store, + budget, + ).read(Stream.value(utf8.encode(r'{"a":1,"\u0061":2}'))), + throwsA(isA()), + ); + }, + ); + + test( + 'exclusive creation collision preserves unowned original bytes', + () async { + final root = await Directory.systemTemp.createTemp('d05-collision-'); + addTearDown(() => root.delete(recursive: true)); + final original = File('${root.path}/collision.ingestion.sqlite'); + await original.writeAsString('not-our-file'); + await expectLater( + BackupIngestionStore.create( + BackupBudget(), + root: root, + attemptId: 'collision', + ), + throwsA(isA()), + ); + expect(await original.readAsString(), 'not-our-file'); + }, + ); + + test( + 'plaintext engine cannot create a production provisional store', + () async { + final root = await Directory.systemTemp.createTemp('d05-plain-'); + addTearDown(() => root.delete(recursive: true)); + await expectLater( + BackupIngestionStore.create(BackupBudget(), root: root), + throwsA(isA()), + ); + expect(await root.list().toList(), isEmpty); + }, + skip: library != null + ? 'Run without cipher override for plaintext guard' + : false, + ); + + for (final count in [255, 256, 257]) { + test( + 'actual encrypted index batch $count commits partial tail and rolls back failure tail', + () async { + final root = await Directory.systemTemp.createTemp('d05-index-batch-'); + addTearDown(() => root.delete(recursive: true)); + for (final fail in [false, true]) { + final store = await BackupIngestionStore.create( + BackupBudget(), + root: root, + ); + addTearDown(store.release); + store.writeNode(null, null, 'object', null); + store.finish(); + Future write() => store.validateRecords(() async { + for (var i = 0; i < count; i++) { + store.addRecord('fixture', 'id-$i', i + 1); + } + if (fail) throw StateError('injected final index-batch failure'); + }); + if (fail) { + await expectLater(write(), throwsStateError); + } else { + await write(); + } + expect( + store.recordCount('fixture'), + fail ? count ~/ 256 * 256 : count, + ); + await store.release(); + expect(await root.list().toList(), isEmpty); + } + }, + skip: library == null ? 'Actual SQLCipher required' : false, + ); + } + test( + 'actual SQLCipher provisional bytes, keyless guard and shared live cleanup', + () async { + final root = await Directory.systemTemp.createTemp('d05-cipher-'); + addTearDown(() => root.delete(recursive: true)); + final budget = BackupBudget(); + final store = await BackupIngestionStore.create(budget, root: root); + addTearDown(store.release); + final id = await BackupJsonReader( + store, + budget, + ).read(Stream.value(utf8.encode('{"note":"PRIVATE-D05-INGESTION"}'))); + store.finish(); + final files = (await root.list().toList()).whereType(); + final file = files.singleWhere((f) => f.path.endsWith('.sqlite')); + expect( + utf8.decode(await file.readAsBytes(), allowMalformed: true), + isNot(contains('PRIVATE-D05-INGESTION')), + ); + final wrong = sql.sqlite3.open(file.path); + try { + expect( + () => wrong.select('SELECT * FROM nodes'), + throwsA(isA()), + ); + } finally { + wrong.dispose(); + } + await RestoreStaging.cleanupAbandoned(root: root); + expect(await file.exists(), isTrue); + expect(store.fields(id, {'note'}), {'note': 'PRIVATE-D05-INGESTION'}); + await store.release(); + expect(await root.list().toList(), isEmpty); + }, + skip: library == null + ? 'Requires actual host SQLCipher, not plaintext test adapter' + : false, + ); +} diff --git a/test/core/backup/backup_instant_representation_test.dart b/test/core/backup/backup_instant_representation_test.dart new file mode 100644 index 00000000..197a02a6 --- /dev/null +++ b/test/core/backup/backup_instant_representation_test.dart @@ -0,0 +1,273 @@ +// Structured synthetic QA evidence is intentionally emitted to captured logs. +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_content.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_validation_contract_test.dart' show validBackup; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const password = 'Synthetic legacy instant password'; + late AppDatabase db; + late BackupService service; + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + Future bytes(Map input) => crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(input))), + password: password, + ); + setUp(() async { + SharedPreferences.setMockInitialValues({}); + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'original sentinel', + ), + ); + service = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: crypto, + operationGate: LocalDataOperationGate(), + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, + ingestionFactory: memoryBackupIngestion, + stagingFactory: memoryRestoreStaging, + processingOwner: testBackupProcessingOwner(), + ); + }); + tearDown(() => db.close()); + test( + 'normal authenticated preview/apply/reexport preserves foreign civil gap raw text', + () async { + final input = validBackup(); + input['schedules'][0]['civilTime'] = '2026-03-08T02:30:00.123456'; + final candidate = await service.previewEncryptedBackup( + await bytes(input), + password, + ); + await service.applyRestore(candidate); + final raw = + (await db + .customSelect('SELECT schedule_time FROM schedules') + .getSingle()) + .read('schedule_time'); + expect(raw, '2026-03-08T02:30:00.123456'); + final output = await service.createEncryptedBackup(password); + final decoded = jsonDecode( + utf8.decode( + await crypto.decrypt(container: output, password: password), + ), + ); + expect(decoded['schedules'][0]['civilTime'], raw); + }, + ); + for (final field in ['createdAt', 'updatedAt']) { + test( + 'template $field cannot infer an offsetless instant from device TZ', + () async { + final input = validBackup(); + final template = { + 'id': 'template', + 'name': 'Preparation', + 'createdAt': '2026-01-01T00:00:00Z', + 'updatedAt': '2026-01-01T00:00:00Z', + 'preparation': [], + }; + template[field] = '2026-03-08T02:30:00'; + expect( + () => BackupContent.fromJson({ + ...input, + 'templates': [template], + }), + throwsA(isA()), + ); + input['templates'] = [template]; + await expectLater( + service.previewEncryptedBackup(await bytes(input), password), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.unsupportedRepresentation, + ), + ), + ); + expect( + (await db.select(db.users).getSingle()).note, + 'original sentinel', + ); + expect(await db.select(db.schedules).get(), isEmpty); + }, + ); + } + test( + 'template explicit offset epochs agree across both codecs and subsecond values reject', + () async { + for (final literal in [ + '2026-01-01T00:00:00Z', + '2026-01-01T09:00:00+09:00', + ]) { + final input = validBackup(); + input['templates'] = [ + { + 'id': 'template', + 'name': 'Prep', + 'createdAt': literal, + 'updatedAt': literal, + 'preparation': [], + }, + ]; + expect( + BackupContent.fromJson(input).templates.single.createdAt, + DateTime.utc(2026), + ); + final candidate = await service.previewEncryptedBackup( + await bytes(input), + password, + ); + await service.applyRestore(candidate); + final stored = await db.select(db.preparationTemplates).getSingle(); + expect(stored.createdAt.toUtc(), DateTime.utc(2026)); + } + final input = validBackup(); + input['templates'] = [ + { + 'id': 'template', + 'name': 'Prep', + 'createdAt': '2026-01-01T00:00:00.001Z', + 'updatedAt': '2026-01-01T00:00:00Z', + 'preparation': [], + }, + ]; + expect( + () => BackupContent.fromJson(input), + throwsA(isA()), + ); + final before = await db.select(db.preparationTemplates).getSingle(); + await expectLater( + service.previewEncryptedBackup(await bytes(input), password), + throwsA(isA()), + ); + expect(await db.select(db.preparationTemplates).getSingle(), before); + }, + ); + for (final field in ['startedAt', 'finishedAt']) { + for (final literal in [ + '2026-03-08T02:30:00', + '2026-11-01T01:30:00', + '2026-01-01T00:00:00.001Z', + ]) { + test( + '$field $literal is unsupported without changing original DB', + () async { + final input = validBackup(); + input['schedules'][0][field] = literal; + final before = (await db.select(db.users).getSingle()); + await expectLater( + service.previewEncryptedBackup(await bytes(input), password), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.unsupportedRepresentation, + ), + ), + ); + expect(await db.select(db.users).getSingle(), before); + expect(await db.select(db.schedules).get(), isEmpty); + }, + ); + } + } + test( + 'explicit Z and +09:00 completion represent the same restored epoch', + () async { + for (final literal in [ + '2026-09-01T00:00:00Z', + '2026-09-01T09:00:00+09:00', + ]) { + final input = validBackup(); + input['schedules'][0]['finishedAt'] = literal; + final candidate = await service.previewEncryptedBackup( + await bytes(input), + password, + ); + await service.applyRestore(candidate); + expect( + (await db.select(db.schedules).getSingle()).finishedAt!.toUtc(), + DateTime.utc(2026, 9, 1), + ); + } + }, + ); + test( + 'UTC conversion outside supported years fails before changing original', + () async { + final before = await db.select(db.users).getSingle(); + for (final literal in [ + '0001-01-01T00:00:00+09:00', + '9999-12-31T23:59:59-09:00', + ]) { + final input = validBackup(); + input['schedules'][0]['finishedAt'] = literal; + await expectLater( + service.previewEncryptedBackup(await bytes(input), password), + throwsA(isA()), + ); + expect(await db.select(db.users).getSingle(), before); + } + }, + ); + test( + 'offsetless cutoff retains literal metadata and commit installs actual local marker', + () async { + const literal = '2026-03-08T02:30:00'; + final input = validBackup()..['cutoff'] = literal; + final candidate = await service.previewEncryptedBackup( + await bytes(input), + password, + ); + expect(candidate.preview.cutoffLiteral, literal); + expect(candidate.preview.cutoff, DateTime.utc(2026, 3, 8, 2, 30)); + expect((await db.select(db.users).getSingle()).note, 'original sentinel'); + final before = DateTime.now().toUtc().subtract( + const Duration(seconds: 1), + ); + await service.applyRestore(candidate); + final row = await db.select(db.users).getSingle(); + expect(row.firstDurableDataAt!.toUtc().isBefore(before), false); + expect( + row.lastDurableDataAt!.toUtc().isAfter(DateTime.now().toUtc()), + false, + ); + expect(row.firstDurableDataAt, row.lastDurableDataAt); + print( + 'D05_LEGACY_CUTOFF deviceTZ=${Platform.environment['TZ']} literal=${candidate.preview.cutoffLiteral} carrier=${candidate.preview.cutoff} actualCommitMarker=${row.firstDurableDataAt!.toUtc()}', + ); + }, + ); +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1', buildNumber: '1'); +} diff --git a/test/core/backup/backup_json_reader_test.dart b/test/core/backup/backup_json_reader_test.dart new file mode 100644 index 00000000..e579d4ef --- /dev/null +++ b/test/core/backup/backup_json_reader_test.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +class JsonTestSink implements BackupJsonSink { + final rows = >[]; + final keys = {}; + @override + int writeNode(int? parent, String? key, String kind, Object? value) { + if (parent != null && !keys.add('$parent/$key')) BackupLimits.invalid(); + rows.add({'parent': parent, 'key': key, 'kind': kind, 'value': value}); + return rows.length; + } +} + +Future read( + String value, { + int chunk = 1, + BackupBudget? budget, +}) async { + final bytes = utf8.encode(value); + Stream> source() async* { + for (var i = 0; i < bytes.length; i += chunk) { + yield bytes.sublist(i, (i + chunk).clamp(0, bytes.length)); + } + } + + final sink = JsonTestSink(); + await BackupJsonReader(sink, budget ?? BackupBudget()).read(source()); + return sink; +} + +void main() { + test( + 'UTF-8, escaped surrogate pair and original key order survive split reads', + () async { + final sink = await read( + r'{"z":"한글😀","a":"\ud83d\ude00","n":[true,null,-1,1.25]}', + ); + expect( + sink.rows.where((r) => r['kind'] == 'string').map((r) => r['value']), + ['한글😀', '😀'], + ); + expect(sink.rows[1]['key'], 'z'); + expect(sink.rows[2]['key'], 'a'); + }, + ); + for (final source in [ + r'{"a":1,"\u0061":2}', + '{"a":1,}', + '[1,]', + '[1 2]', + '{"a" 1}', + '1 2', + '01', + r'"\ud800"', + r'"\udc00"', + '"\n"', + '{', + '', + ]) { + test('reject malformed or duplicate JSON: ${jsonEncode(source)}', () async { + await expectLater(read(source), throwsA(isA())); + }); + } + test( + 'maximum decoded string is accepted even when split inside escapes', + () async { + final sink = await read( + '"${'a' * BackupLimits.stringBytes}"', + chunk: 4093, + ); + expect( + (sink.rows.single['value'] as String).length, + BackupLimits.stringBytes, + ); + }, + ); + test( + 'giant scalar is refused before a sink can allocate its value', + () async { + final sink = JsonTestSink(); + final budget = BackupBudget(); + final reader = BackupJsonReader(sink, budget); + await expectLater( + reader.read( + Stream.value( + utf8.encode('"${'a' * (BackupLimits.stringBytes + 1)}"'), + ), + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + expect(sink.rows, isEmpty); + expect(budget.largestTrackedBufferBytes, BackupLimits.stringBytes); + }, + ); + test('key bound differs from string bound', () async { + await expectLater( + read('{"${'a' * 513}":0}', chunk: 128), + throwsA(isA()), + ); + }); + test('container depth has exact 32 boundary', () async { + await read('${'[' * 32}0${']' * 32}'); + await expectLater( + read('${'[' * 33}0${']' * 33}'), + throwsA(isA()), + ); + }); + test('cancellation is preserved and not relabelled malformed JSON', () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire(); + lease.requestCancellation(); + await expectLater( + read('{}', budget: BackupBudget(lease: lease)), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.userCancelled, + ), + ), + ); + lease.release(); + }); +} diff --git a/test/core/backup/backup_large_ingestion_test.dart b/test/core/backup/backup_large_ingestion_test.dart new file mode 100644 index 00000000..581c3ceb --- /dev/null +++ b/test/core/backup/backup_large_ingestion_test.dart @@ -0,0 +1,215 @@ +// Structured synthetic QA evidence is intentionally emitted to captured logs. +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/backup_file_export_port.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/open.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_validation_contract_test.dart' show validBackup; + +Stream> fixture() async* { + final header = validBackup()..remove('schedules'); + final json = jsonEncode(header); + yield utf8.encode('${json.substring(0, json.length - 1)},"schedules":['); + final prototype = (validBackup()['schedules'] as List).single as Map; + for (var i = 0; i < 10000; i++) { + yield utf8.encode( + '${i == 0 ? '' : ','}${jsonEncode({ + ...prototype, + 'id': 'schedule-$i', + 'place': {'id': 'place-$i', 'name': 'Fixture $i'}, + })}', + ); + } + yield utf8.encode(']}'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (library != null) open.overrideForAll(() => DynamicLibrary.open(library)); + test( + '10000 actual encrypted records authenticate, materialize and bounded read back within original budgets', + () async { + final root = await Directory.systemTemp.createTemp('d05-large-'); + addTearDown(() => root.delete(recursive: true)); + var plaintextLength = 0; + await for (final chunk in fixture()) { + plaintextLength += chunk.length; + } + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + const password = 'D05 synthetic large fixture password'; + final file = File('${root.path}/fixture.ontimebackup'); + final sink = file.openWrite(); + final total = Stopwatch()..start(); + await sink.addStream( + crypto.encryptStream( + plaintext: fixture(), + plaintextLength: plaintextLength, + password: password, + ), + ); + await sink.flush(); + await sink.close(); + final productionStartRss = ProcessInfo.currentRss; + final phase = Stopwatch()..start(); + final budget = BackupBudget(); + final content = await BackupValidatedIngestion.decrypt( + ciphertext: file.openRead(), + password: password, + crypto: crypto, + budget: budget, + nowUtc: DateTime.utc(2026, 9, 24), + createStore: (budget) => BackupIngestionStore.create( + budget, + root: Directory('${root.path}/ingestion'), + ), + ); + addTearDown(content.release); + final ingestionMs = phase.elapsedMilliseconds; + final rawBytes = await Directory('${root.path}/ingestion') + .list() + .where((e) => e is File) + .asyncMap((e) => (e as File).length()) + .fold(0, (a, b) => a + b); + final stage = await RestoreStaging.create( + root: Directory('${root.path}/materialized'), + ); + addTearDown(stage.release); + await content.materialize(stage.database, pendingCleanup: false); + final materializedMs = phase.elapsedMilliseconds - ingestionMs; + await content.validateReadBack(stage.database, pendingCleanup: false); + expect(content.preview.scheduleCount, 10000); + expect( + (await stage.database + .customSelect('SELECT count(*) AS n FROM schedules') + .getSingle()) + .read('n'), + 10000, + ); + final first = await (stage.database.customSelect( + "SELECT schedule_name,place_id FROM schedules WHERE id='schedule-0'", + )).getSingle(); + final last = await (stage.database.customSelect( + "SELECT schedule_name,place_id FROM schedules WHERE id='schedule-9999'", + )).getSingle(); + expect(first.read('place_id'), 'place-0'); + expect(last.read('place_id'), 'place-9999'); + expect(budget.work, lessThanOrEqualTo(BackupLimits.work)); + final readbackMs = + phase.elapsedMilliseconds - ingestionMs - materializedMs; + final exported = File('${root.path}/roundtrip.ontimebackup'); + final exportFile = await exported.open(mode: FileMode.write); + addTearDown(exportFile.close); + var channelSequence = 0; + var maxChannel = 0; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(NativeBackupFileExportPort.channel, ( + call, + ) async { + final args = call.arguments as Map; + switch (call.method) { + case 'begin': + return 'synthetic-export-attempt'; + case 'append': + expect(args['sequence'], channelSequence++); + final bytes = args['bytes'] as Uint8List; + expect(bytes.length, lessThanOrEqualTo(65536)); + if (bytes.length > maxChannel) maxChannel = bytes.length; + await exportFile.writeFrom(bytes); + return true; + case 'seal': + await exportFile.flush(); + expect(await exported.length(), args['length']); + expect( + (await sha256.bind(exported.openRead()).single).toString(), + args['sha256'], + ); + return true; + case 'export': + return {'outcome': 'saved', 'cleanupUnconfirmed': false}; + case 'cancel': + return {'outcome': 'cancelled', 'cleanupUnconfirmed': false}; + default: + fail('unexpected native method'); + } + }); + addTearDown( + () => messenger.setMockMethodCallHandler( + NativeBackupFileExportPort.channel, + null, + ), + ); + BackupBudget? exportBudget; + final service = BackupService( + stage.database, + _Metadata(), + NoopAlarmCleanup(), + crypto: crypto, + processingOwner: BackupProcessingOwner(), + operationGate: LocalDataOperationGate(), + stagingFactory: () => RestoreStaging.create( + root: Directory('${root.path}/export-snapshot'), + ), + ingestionFactory: (b) { + exportBudget = b; + return BackupIngestionStore.create( + b, + root: Directory('${root.path}/export-validation'), + ); + }, + ); + final exportStart = Stopwatch()..start(); + expect( + await service.exportToUserSelectedFile(password), + BackupExportResult.saved, + ); + expect(exportBudget!.work, lessThanOrEqualTo(BackupLimits.work)); + final reimportBudget = BackupBudget(); + final reimport = await BackupValidatedIngestion.decrypt( + ciphertext: exported.openRead(), + password: password, + crypto: crypto, + budget: reimportBudget, + createStore: (b) => BackupIngestionStore.create( + b, + root: Directory('${root.path}/reimport'), + ), + ); + addTearDown(reimport.release); + expect(reimport.preview.scheduleCount, 10000); + print( + 'D05_LARGE_EXPORT_ROUNDTRIP ${jsonEncode({'exportWork': exportBudget!.work, 'exportFirstPassBytes': exportBudget!.plainBytes, 'exportCipherBytes': await exported.length(), 'channelMaxBytes': maxChannel, 'channelMessages': channelSequence, 'exportAndReimportMs': exportStart.elapsedMilliseconds, 'reimportWork': reimportBudget.work, 'scope': 'actual host SQLCipher + actual crypto; native channel/file seal mock, not OS provider'})}', + ); + print( + 'D05_LARGE_HOST_SQLCIPHER ${jsonEncode({'schedules': 10000, 'plaintextBytes': plaintextLength, 'ciphertextBytes': await file.length(), 'rawEncryptedStoreBytes': rawBytes, 'records': budget.records, 'work': budget.work, 'largestTrackedBufferBytes': budget.largestTrackedBufferBytes, 'ingestionMs': ingestionMs, 'materializationMs': materializedMs, 'readbackMs': readbackMs, 'fixtureElapsedMs': total.elapsedMilliseconds, 'rssBeforeProductionIngestion': productionStartRss, 'rssAfterReadback': ProcessInfo.currentRss, 'hostProcessPeakAcrossFixture': ProcessInfo.maxRss, 'measurementScope': 'Flutter host test process includes fixture crypto producer; not mobile or isolated parser RSS'})}', + ); + }, + skip: library == null + ? 'Requires actual SQLCipher library; not counted as a pass without it' + : false, + timeout: const Timeout(Duration(minutes: 8)), + ); +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1.0.1', buildNumber: '1'); +} diff --git a/test/core/backup/backup_parser_budget_acceptance_test.dart b/test/core/backup/backup_parser_budget_acceptance_test.dart new file mode 100644 index 00000000..7adedce7 --- /dev/null +++ b/test/core/backup/backup_parser_budget_acceptance_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +void main() { + test( + 'T03 C09 exact remaining work admits a document and next byte pass cannot reset it', + () async { + final budget = BackupBudget()..visit(BackupLimits.work - 3); + final sink = _Sink(); + await BackupJsonReader( + sink, + budget, + ).read(Stream.value(utf8.encode('[0,1]'))); + expect(sink.nodes, 3); + expect(budget.work, BackupLimits.work); + final nextSink = _Sink(); + await expectLater( + BackupJsonReader( + nextSink, + budget.bytePass(), + ).read(Stream.value(utf8.encode('0'))), + throwsA( + isA().having( + (e) => e.limit, + 'limit', + 'work', + ), + ), + ); + expect(nextSink.nodes, 0); + expect(budget.work, BackupLimits.work); + }, + ); + + test( + 'T03 C09 unknown JSON nodes beyond remaining work never reach the storage sink', + () async { + final budget = BackupBudget()..visit(BackupLimits.work - 3); + final sink = _Sink(); + await expectLater( + BackupJsonReader( + sink, + budget, + ).read(Stream.value(utf8.encode('[0,1,2]'))), + throwsA( + isA().having( + (e) => e.limit, + 'limit', + 'work', + ), + ), + ); + expect(sink.nodes, 3); + expect(budget.work, BackupLimits.work); + }, + ); + + test( + 'T03 C09 oversized numeric token is bounded before parsing or storage', + () async { + final sink = _Sink(); + final budget = BackupBudget(); + Stream> input() async* { + for (var at = 0; at < BackupLimits.stringBytes; at += 1024) { + yield List.filled(1024, 49); + } + yield [49]; + } + + await expectLater( + BackupJsonReader(sink, budget).read(input()), + throwsA( + isA().having( + (e) => e.limit, + 'limit', + 'token', + ), + ), + ); + expect(sink.nodes, 0); + }, + ); +} + +class _Sink implements BackupJsonSink { + int nodes = 0; + @override + int writeNode(int? parent, String? key, String kind, Object? scalar) => + ++nodes; +} diff --git a/test/core/backup/backup_recurring_time_review_cipher_test.dart b/test/core/backup/backup_recurring_time_review_cipher_test.dart new file mode 100644 index 00000000..a49ccbe8 --- /dev/null +++ b/test/core/backup/backup_recurring_time_review_cipher_test.dart @@ -0,0 +1,316 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +// Real secretstream authentication, SQLCipher ingestion/staging/active files, +// service replacement and cold reopen. Native alarm delivery is not exercised. +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/open.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/backup_recurring_time_review.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_validated_ingestion_test.dart' show recurringBackup; + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1.0.0', buildNumber: '1'); +} + +class _Alarms extends NoopAlarmCleanup { + int calls = 0; + @override + Future forDataReplacement() async { + calls++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (library != null) open.overrideForAll(() => DynamicLibrary.open(library)); + for (final spare in [null, 60]) { + for (final empty in [false, true]) { + test( + 'authenticated ${empty ? 'zero-row' : 'materialized'} recurrence spare=$spare uses encrypted candidate and applies only verified ready', + () async { + SharedPreferences.setMockInitialValues({}); + final root = await Directory.systemTemp.createTemp( + 'a10-recurring-cipher-', + ); + final liveFile = File('${root.path}/active.sqlite'); + AppDatabase database() => AppDatabase.forTesting( + NativeDatabase( + liveFile, + setup: (db) => + db.execute("PRAGMA key = 'owned-test-installation-key'"), + ), + ); + var active = database(); + final gate = LocalDataOperationGate(); + final processing = BackupProcessingOwner(); + final selections = []; + addTearDown(() async { + for (final value in selections.reversed) { + await value.dispose(); + } + await active.close(); + gate.dispose(); + await root.delete(recursive: true); + }); + await active.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 7), + note: 'ACTIVE-UNCHANGED-BEFORE-CONFIRM', + ), + ); + final originalUser = await active.select(active.users).getSingle(); + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + final alarms = _Alarms(); + var stages = 0; + AppDatabase? staged; + final service = BackupService( + active, + _Metadata(), + alarms, + now: () => DateTime.utc(2030), + crypto: crypto, + processingOwner: processing, + operationGate: gate, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: () async {}, + ingestionFactory: (b) => BackupIngestionStore.create( + b, + root: Directory('${root.path}/ingestion'), + ), + stagingFactory: () async { + stages++; + final value = await RestoreStaging.create( + root: Directory('${root.path}/staging'), + ); + staged = value.database; + return value; + }, + ); + final input = recurringBackup(year: 2031); + final raw = input['recurring']['segments'][0]; + final rule = + jsonDecode(raw['ruleJson'] as String) as Map; + rule['zone'] = 'Removed/Zone'; + rule['count'] = 5; + raw['ruleJson'] = jsonEncode(rule); + final base = + jsonDecode(raw['scheduleJson'] as String) as Map; + base['zone'] = 'Removed/Zone'; + base['spare'] = spare; + input['profile']['spareTimeMinutes'] = 60; + input['schedules'][0]['spareTimeMinutes'] = spare; + raw['scheduleJson'] = jsonEncode(base); + input['schedules'][0]['timeZoneId'] = 'Removed/Zone'; + if (empty) { + input['schedules'] = []; + } else { + final other = + recurringBackup(year: 2031)['schedules'][0] + as Map; + other.removeWhere( + (key, _) => + key.startsWith('recurring') || + key == 'preparationDefinitionId', + ); + other['id'] = 'other'; + other['civilTime'] = '2031-01-03T08:30:00.000'; + input['schedules'].add(other); + } + const password = 'actual encrypted recurring plan password'; + final ciphertext = await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(input))), + password: password, + ); + final originalBytes = Uint8List.fromList(ciphertext); + final review = + await service.reviewEncryptedBackup(ciphertext, password) + as BackupTimeReviewInput; + selections.add(review); + expect(stages, 0); + expect(alarms.calls, 0); + expect(processing.active, isNotNull); + final api = review as BackupRecurringTimeReviewPort; + final issue = (await review.issues()).items.singleWhere( + (e) => e.kind == BackupTimeFieldKind.recurrenceRule, + ); + final summary = review.summary; + Future planAt(int hour) => + api.reviewRecurrence( + BackupRecurringTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: DateTime.utc(2031, 1, 3, hour), + timeZoneId: 'UTC', + count: 3, + ), + endExplicitlyChosen: true, + replaceWholeSourceInterval: !empty, + ), + ); + var plan = await planAt(9); + expect( + plan.conflicts.earliestPreparationUtc, + DateTime.utc(2031, 1, 3, spare == null ? 9 : 8), + ); + expect(plan.conflicts.conflicts.isNotEmpty, !empty && spare == 60); + if (!empty && spare == 60) { + await expectLater( + api.chooseRecurrence( + BackupRecurringTimeChoice( + plan: plan, + confirmedDetachedIds: {}, + confirmedUnmatchedExclusions: {}, + confirmedWholeSourceReplacement: true, + ), + ), + throwsA(isA()), + ); + expect(await active.select(active.users).getSingle(), originalUser); + plan = await planAt(10); + expect(plan.conflicts.conflicts, isEmpty); + } + await api.chooseRecurrence( + BackupRecurringTimeChoice( + plan: plan, + confirmedDetachedIds: {}, + confirmedUnmatchedExclusions: {}, + acknowledgedPossibleIds: plan.conflicts.possibleOverlaps + .map((e) => e.id) + .toSet(), + confirmedWholeSourceReplacement: !empty, + ), + ); + expect(review.summary.independentStructureChecked, isFalse); + expect(stages, 0); + expect(await active.select(active.users).getSingle(), originalUser); + expect(await active.select(active.schedules).get(), isEmpty); + expect(alarms.calls, 0); + final ready = await review.revalidate() as BackupRestoreCandidate; + selections.add(ready); + expect(stages, 1); + expect( + (await staged!.customSelect('PRAGMA cipher_version').getSingle()) + .data + .values + .single, + isNotEmpty, + ); + expect( + (await staged!.select(staged!.recurringScheduleSegments).get()), + hasLength(2), + ); + expect(ready.preview.scheduleCount, empty ? 0 : 2); + expect(ciphertext, originalBytes); + expect(alarms.calls, 0); + expect(gate.generation, 0); + for (final directory in ['ingestion', 'staging']) { + final files = + (await Directory('${root.path}/$directory').list().toList()) + .whereType() + .where((f) => f.path.endsWith('.sqlite')) + .toList(); + expect(files, isNotEmpty); + for (final file in files) { + expect( + utf8.decode( + (await file.readAsBytes()).take(16).toList(), + allowMalformed: true, + ), + isNot(startsWith('SQLite format 3')), + ); + } + } + await review + .dispose(); // Transferred owner cannot release the ready lease. + expect(processing.active, isNotNull); + await service.applyRestore(ready); + expect(alarms.calls, 1); + expect(gate.generation, 1); + expect(processing.active, isNull); + await active.close(); + active = database(); + final rows = await active.select(active.schedules).get(); + expect(rows, hasLength(empty ? 0 : 2)); + if (!empty) { + final row = rows.singleWhere((e) => e.id == 's1'); + expect(row.scheduleSpareTime?.inMinutes, spare); + expect(row.id, 's1'); + expect(row.recurringSegmentId, isNot('seg')); + expect(row.recurringOrdinal, 1); + expect(row.preparationDefinitionId, 'def'); + expect(row.timeZoneId, 'UTC'); + expect(row.isStarted, isFalse); + expect(row.requiresStartConfirmation, isTrue); + final rawSchedule = (await active.scheduleDao.getScheduleById( + 's1', + )).toScheduleEntity(); + final actual = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + rawSchedule, + const PreparationWithTimeEntity(preparationStepList: []), + timeResolution: ScheduleTimeResolver.resolve( + rawSchedule, + nowUtc: DateTime.utc(2030), + ), + ); + expect(actual.totalDuration, Duration(minutes: spare ?? 0)); + expect( + actual.preparationStartTime, + plan.conflicts.earliestPreparationUtc, + ); + expect( + (await active.select(active.users).getSingle()).spareTime, + 60, + ); + } + final segments = await active + .select(active.recurringScheduleSegments) + .get(); + expect(segments, hasLength(2)); + final original = segments.singleWhere((e) => e.id == 'seg'); + expect(original.ruleJson, raw['ruleJson']); + expect(original.fromSlot, raw['fromSlot']); + expect( + CivilDateTime.parse(original.beforeSlot!), + CivilDateTime.parse(original.fromSlot), + ); + expect(ciphertext, originalBytes); + }, + skip: library == null + ? 'Requires actual host SQLCipher library' + : false, + ); + } + } +} diff --git a/test/core/backup/backup_recurring_time_review_test.dart b/test/core/backup/backup_recurring_time_review_test.dart new file mode 100644 index 00000000..52235e46 --- /dev/null +++ b/test/core/backup/backup_recurring_time_review_test.dart @@ -0,0 +1,575 @@ +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +// Authenticated candidate + fresh whole-graph validation + real SQLite readback. +// This fixture is plaintext in-memory; SQLCipher/native proof is separate. +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/backup_recurring_time_review.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_validated_ingestion_test.dart' show recurringBackup; + +class _Ready extends BackupRestoreInput { + _Ready(this.data, this.db, this.lease); + final BackupValidatedIngestion data; + final AppDatabase db; + final BackupProcessingLease lease; + bool closed = false; + @override + BackupRestorePreview get preview => data.preview; + @override + Future dispose() async { + if (closed) return; + await db.close(); + await data.release(); + lease.release(); + closed = true; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late DateTime now; + late BackupProcessingOwner processing; + late BackupIngestionStore store; + final owned = []; + var readyCalls = 0; + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + setUp(() { + now = DateTime.utc(2030); + processing = BackupProcessingOwner(); + owned.clear(); + readyCalls = 0; + }); + tearDown(() async { + for (final input in owned.reversed) { + await input.dispose(); + } + }); + Map input({bool empty = false}) { + final value = recurringBackup(year: 2031); + final segment = value['recurring']['segments'][0]; + final rule = + jsonDecode(segment['ruleJson'] as String) as Map; + rule['zone'] = 'Removed/Zone'; + rule['count'] = 5; + segment['ruleJson'] = jsonEncode(rule); + final base = + jsonDecode(segment['scheduleJson'] as String) as Map; + base['zone'] = 'Removed/Zone'; + segment['scheduleJson'] = jsonEncode(base); + value['schedules'][0]['timeZoneId'] = 'Removed/Zone'; + if (empty) value['schedules'] = []; + return value; + } + + Future open(Map value) async { + final bytes = await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(value))), + password: 'recurrence review password', + ); + final lease = processing.acquire(); + final selected = await BackupAuthenticatedTimeReview.decrypt( + ciphertext: Stream.value(bytes), + password: 'recurrence review password', + crypto: crypto, + budget: BackupBudget(lease: lease), + createStore: (budget) async => + store = BackupIngestionStore.memoryForTesting(budget), + now: () => now, + ready: (data) async { + readyCalls++; + final db = AppDatabase.forTesting(NativeDatabase.memory()); + try { + await data.materialize(db, pendingCleanup: false); + await data.validateReadBack(db, pendingCleanup: false); + } catch (_) { + await db.close(); + rethrow; + } + return _Ready(data, db, lease); + }, + ); + owned.add(selected); + return selected as BackupTimeReviewInput; + } + + Future draft( + BackupTimeReviewInput owner, { + bool whole = false, + bool explicitEnd = true, + int? count = 3, + DateTime? start, + String zone = 'UTC', + RepeatedCivilTime? repeatedTime, + }) async { + final issue = (await owner.issues()).items.firstWhere( + (i) => i.kind == BackupTimeFieldKind.recurrenceRule, + ); + final summary = owner.summary; + return BackupRecurringTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start ?? DateTime.utc(2031, 1, 3, 9), + timeZoneId: zone, + repeatedTime: repeatedTime, + count: count, + ), + endExplicitlyChosen: explicitEnd, + replaceWholeSourceInterval: whole, + ); + } + + BackupRecurringTimeReviewPort port(BackupTimeReviewInput owner) => + owner as BackupRecurringTimeReviewPort; + BackupRecurringTimeChoice choice( + BackupRecurringTimePlan plan, { + bool? whole, + Set? detached, + Set? unmatched, + }) => BackupRecurringTimeChoice( + plan: plan, + confirmedWholeSourceReplacement: whole ?? plan.replacesWholeSourceInterval, + confirmedDetachedIds: + detached ?? + { + for (final r in plan.mapping.rows) + if (r.detached) r.original.id, + }, + confirmedUnmatchedExclusions: + unmatched ?? + { + for (final e in plan.mapping.exclusions) + if (e.slot == null) '${e.original.segmentId}\n${e.original.slot}', + }, + acknowledgedPossibleIds: plan.conflicts.possibleOverlaps + .map((e) => e.id) + .toSet(), + ); + test( + 'zero materialized rows have no fabricated anchor and require explicit bounded end', + () async { + final owner = await open(input(empty: true)); + final api = port(owner); + expect( + (await api.recurrenceRule((await draft(owner)).issueId)).timeZoneId, + 'Removed/Zone', + ); + await expectLater( + api.reviewRecurrence( + await draft(owner, explicitEnd: false, count: null), + ), + throwsA(isA()), + ); + final plan = await api.reviewRecurrence(await draft(owner)); + expect(plan.anchor.scheduleId, isNull); + expect(plan.anchor.originalOrdinal, isNull); + expect(plan.mapping.rows, isEmpty); + expect(plan.closeAt, DateTime.utc(2031, 1, 1, 9)); + expect(readyCalls, 0); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + expect(await ready.db.select(ready.db.schedules).get(), isEmpty); + final segments = await ready.db + .select(ready.db.recurringScheduleSegments) + .get(); + expect(segments, hasLength(2)); + expect( + segments.singleWhere((s) => s.id == 'seg').beforeSlot, + '2031-01-01T09:00:00.000', + ); + expect(segments.singleWhere((s) => s.id != 'seg').seriesId, 'series'); + expect(readyCalls, 1); + }, + ); + test( + 'future unknown prefix requires separate whole-source acknowledgement and preserves stable row', + () async { + final original = input(); + final owner = await open(original); + final api = port(owner); + await expectLater( + api.reviewRecurrence(await draft(owner)), + throwsA(isA()), + ); + final plan = await api.reviewRecurrence(await draft(owner, whole: true)); + expect(plan.anchor.scheduleId, 's1'); + expect(plan.anchor.originalOrdinal, 3); + expect(plan.anchor.originalSlot, DateTime.utc(2031, 1, 3, 9)); + expect(plan.closeAt, DateTime.utc(2031, 1, 1, 9)); + final revision = owner.summary.revision; + await expectLater( + api.chooseRecurrence(choice(plan, whole: false)), + throwsA(isA()), + ); + expect(owner.summary.revision, revision); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + final row = await ready.db.select(ready.db.schedules).getSingle(); + expect(row.id, 's1'); + expect(row.preparationDefinitionId, 'def'); + expect(row.recurringSegmentId, isNot('seg')); + expect(row.recurringOrdinal, 1); + expect(row.timeZoneId, 'UTC'); + final old = + (await ready.db.select(ready.db.recurringScheduleSegments).get()) + .singleWhere((r) => r.id == 'seg'); + expect(old.ruleJson, original['recurring']['segments'][0]['ruleJson']); + expect(old.beforeSlot, '2031-01-01T09:00:00.000'); + final oldNode = store.record('segment', 'seg')!['node'] as int; + expect(store.originalTimeScalar(oldNode, 'beforeSlot'), isNull); + }, + ); + test( + 'one plan cannot be replayed after candidate revision changes', + () async { + final owner = await open(input(empty: true)); + final api = port(owner); + final plan = await api.reviewRecurrence(await draft(owner)); + await api.chooseRecurrence(choice(plan)); + await expectLater( + api.chooseRecurrence(choice(plan)), + throwsA(isA()), + ); + expect(readyCalls, 0); + }, + ); + test( + 'known collision blocks candidate edit and cannot be labelled safe', + () async { + final value = input(empty: true); + final other = + recurringBackup(year: 2031)['schedules'][0] as Map; + other.removeWhere( + (key, _) => + key.startsWith('recurring') || key == 'preparationDefinitionId', + ); + other['id'] = 'other'; + other['civilTime'] = '2031-01-03T09:00:00.000'; + value['schedules'] = [other]; + final owner = await open(value); + final api = port(owner); + final plan = await api.reviewRecurrence(await draft(owner)); + expect(plan.conflicts.conflicts, isNotEmpty); + final revision = owner.summary.revision; + await expectLater( + api.chooseRecurrence(choice(plan)), + throwsA(isA()), + ); + expect(owner.summary.revision, revision); + expect(readyCalls, 0); + }, + ); + test( + 'mapped materialized explicit spare contributes to its conflict interval', + () async { + final value = input(); + value['profile']['spareTimeMinutes'] = 60; + value['schedules'][0]['spareTimeMinutes'] = 60; + final raw = value['recurring']['segments'][0]; + final base = + jsonDecode(raw['scheduleJson'] as String) as Map; + base['spare'] = 60; + raw['scheduleJson'] = jsonEncode(base); + final other = + recurringBackup(year: 2031)['schedules'][0] as Map; + other.removeWhere( + (key, _) => + key.startsWith('recurring') || key == 'preparationDefinitionId', + ); + other['id'] = 'other'; + other['civilTime'] = '2031-01-03T08:30:00.000'; + other['spareTimeMinutes'] = 0; + value['schedules'].add(other); + final owner = await open(value); + final api = port(owner); + final plan = await api.reviewRecurrence(await draft(owner, whole: true)); + expect( + plan.conflicts.conflicts.map((e) => e.other.schedule.id), + contains('other'), + ); + expect( + plan.mapping.rows.single.original.scheduleSpareTime, + const Duration(minutes: 60), + ); + await expectLater( + api.chooseRecurrence(choice(plan)), + throwsA(isA()), + ); + expect(owner.summary.revision, plan.draft.revision); + expect(readyCalls, 0); + }, + ); + test( + 'protected start and matched tombstone keep originals while exclusions transfer to the new rule', + () async { + final value = input(); + final protected = + jsonDecode(jsonEncode(value['schedules'][0])) as Map; + protected.addAll({ + 'id': 'protected', + 'civilTime': '2031-01-04T09:00:00.000', + 'recurringSlotKey': '2031-01-04T09:00:00.000', + 'recurringOrdinal': 4, + 'startedAt': '2029-12-31T09:00:00.000Z', + 'preparationFrozen': true, + }); + value['schedules'].add(protected); + value['recurring']['exclusions'] = [ + { + 'segmentId': 'seg', + 'slotKey': '2031-01-02T09:00:00.000', + 'ordinal': 2, + }, + ]; + final owner = await open(value); + final api = port(owner); + final plan = await api.reviewRecurrence( + await draft(owner, whole: true, start: DateTime.utc(2031, 1, 2, 9)), + ); + expect(plan.mapping.protectedRows.map((r) => r.id), ['protected']); + expect(plan.mapping.exclusions.single.slot!.ordinal, 1); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + final rows = await ready.db.select(ready.db.schedules).get(); + final retained = rows.singleWhere((r) => r.id == 'protected'); + expect(retained.recurringSegmentId, 'seg'); + expect(retained.recurringOrdinal, 4); + expect(retained.timeZoneId, 'Removed/Zone'); + expect(retained.occurrenceOffsetSeconds, 0); + expect(retained.preparationFrozen, isTrue); + expect(retained.startedAt!.toUtc(), DateTime.utc(2029, 12, 31, 9)); + final tombstones = await ready.db + .select(ready.db.recurringScheduleExclusions) + .get(); + expect(tombstones, hasLength(3)); + expect(tombstones.singleWhere((e) => e.segmentId == 'seg').ordinal, 2); + expect( + tombstones + .where((e) => e.segmentId != 'seg') + .map((e) => e.ordinal) + .toSet(), + {1, 3}, + ); + }, + ); + test( + 'unmatched original exclusion needs exact acknowledgement before any edit', + () async { + final value = input(); + value['recurring']['exclusions'] = [ + { + 'segmentId': 'seg', + 'slotKey': '2031-01-02T09:00:00.000', + 'ordinal': 2, + }, + ]; + final owner = await open(value); + final api = port(owner); + final plan = await api.reviewRecurrence(await draft(owner, whole: true)); + expect(plan.mapping.exclusions.single.slot, isNull); + final revision = owner.summary.revision; + await expectLater( + api.chooseRecurrence(choice(plan, unmatched: {})), + throwsA(isA()), + ); + expect(owner.summary.revision, revision); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + final original = await ready.db + .select(ready.db.recurringScheduleExclusions) + .getSingle(); + expect(original.segmentId, 'seg'); + expect(original.ordinal, 2); + }, + ); + test( + 'an old slot with future time override is not omitted from whole source mapping', + () async { + final value = input(); + final raw = value['recurring']['segments'][0]; + final rule = + jsonDecode(raw['ruleJson'] as String) as Map; + rule['start'] = '2029-01-01T09:00:00.000'; + raw['ruleJson'] = jsonEncode(rule); + raw['fromSlot'] = rule['start']; + final base = + jsonDecode(raw['scheduleJson'] as String) as Map; + base['time'] = rule['start']; + raw['scheduleJson'] = jsonEncode(base); + value['schedules'][0].addAll({ + 'recurringSlotKey': '2029-01-03T09:00:00.000', + 'civilTime': '2031-01-03T13:00:00.000', + 'timeZoneId': 'UTC', + 'recurringOverrides': 'time', + }); + final owner = await open(value); + final api = port(owner); + final plan = await api.reviewRecurrence(await draft(owner, whole: true)); + expect(plan.mapping.rows.single.original.id, 's1'); + expect(plan.mapping.rows.single.detached, isTrue); + expect(plan.unresolvedOverrides, isEmpty); + await expectLater( + api.chooseRecurrence(choice(plan, detached: {})), + throwsA(isA()), + ); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + final row = await ready.db.select(ready.db.schedules).getSingle(); + expect(row.id, 's1'); + expect(row.recurringSegmentId, isNull); + expect(row.preparationDefinitionId, 'def'); + expect(row.timeZoneId, 'UTC'); + expect(row.scheduleTime, DateTime.utc(2031, 1, 3, 13)); + }, + ); + test( + 'passing the first preparation boundary retires a reviewed plan without writes', + () async { + final owner = await open(input(empty: true)); + final api = port(owner); + final plan = await api.reviewRecurrence(await draft(owner)); + final revision = owner.summary.revision; + now = DateTime.utc(2032); + await expectLater( + api.chooseRecurrence(choice(plan)), + throwsA(isA()), + ); + expect(owner.summary.revision, revision); + expect(readyCalls, 0); + }, + ); + test( + 'zero-row DST gap start skips the gap and survives full SQLite readback', + () async { + final owner = await open(input(empty: true)); + final api = port(owner); + final plan = await api.reviewRecurrence( + await draft( + owner, + start: DateTime.utc(2031, 3, 9, 2, 30), + zone: 'America/New_York', + count: 2, + ), + ); + expect( + plan.mapping.firstSlot!.civilTime, + DateTime.utc(2031, 3, 10, 2, 30), + ); + expect(plan.mapping.firstSlot!.ordinal, 1); + expect(plan.mapping.firstSlot!.offsetSeconds, -14400); + expect( + plan.mapping.firstSlot!.instantUtc, + DateTime.utc(2031, 3, 10, 6, 30), + ); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + final segment = + (await ready.db.select(ready.db.recurringScheduleSegments).get()) + .singleWhere((e) => e.id != 'seg'); + final rule = jsonDecode(segment.ruleJson) as Map; + final base = jsonDecode(segment.scheduleJson) as Map; + expect(rule['start'], '2031-03-09T02:30:00.000'); + expect(rule['count'], 2); + expect(base['zone'], 'America/New_York'); + expect(base['offset'], -14400); + expect(await ready.db.select(ready.db.schedules).get(), isEmpty); + }, + ); + test( + 'overlap requires an explicit side and second occurrence survives full validation', + () async { + final owner = await open(input(empty: true)); + final api = port(owner); + await expectLater( + api.reviewRecurrence( + await draft( + owner, + start: DateTime.utc(2031, 11, 2, 1, 30), + zone: 'America/New_York', + count: 2, + ), + ), + throwsA(isA()), + ); + final plan = await api.reviewRecurrence( + await draft( + owner, + start: DateTime.utc(2031, 11, 2, 1, 30), + zone: 'America/New_York', + count: 2, + repeatedTime: RepeatedCivilTime.second, + ), + ); + expect( + plan.mapping.firstSlot!.instantUtc, + DateTime.utc(2031, 11, 2, 6, 30), + ); + expect(plan.mapping.firstSlot!.offsetSeconds, -18000); + await api.chooseRecurrence(choice(plan)); + final ready = await owner.revalidate() as _Ready; + owned.add(ready); + final segment = + (await ready.db.select(ready.db.recurringScheduleSegments).get()) + .singleWhere((e) => e.id != 'seg'); + expect(jsonDecode(segment.ruleJson)['repeatedTime'], 'second'); + expect(jsonDecode(segment.scheduleJson)['offset'], -18000); + }, + ); + test( + 'rule review and cancellation do not edit the candidate graph', + () async { + final value = input(); + final owner = await open(value); + final api = port(owner); + final segmentNode = store.record('segment', 'seg')!['node'] as int; + final rowNode = store.record('schedule', 's1')!['node'] as int; + Object snapshot() => [ + store.fields(segmentNode, { + 'id', + 'fromSlot', + 'beforeSlot', + 'ruleJson', + 'scheduleJson', + 'preparationId', + }), + store.fields(rowNode, { + 'id', + 'civilTime', + 'timeZoneId', + 'occurrenceOffsetSeconds', + 'recurringSegmentId', + 'recurringSlotKey', + 'recurringOrdinal', + 'recurringOverrides', + 'preparationDefinitionId', + }), + ]; + final before = snapshot(); + await api.reviewRecurrence(await draft(owner, whole: true)); + expect(snapshot(), before); + expect(readyCalls, 0); + }, + ); +} diff --git a/test/core/backup/backup_restore_picker_test.dart b/test/core/backup/backup_restore_picker_test.dart index c824cc4f..aaebc42a 100644 --- a/test/core/backup/backup_restore_picker_test.dart +++ b/test/core/backup/backup_restore_picker_test.dart @@ -1,14 +1,12 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import '../../helpers/noop_alarm_cleanup.dart'; -import 'dart:io'; -import 'dart:typed_data'; import 'package:drift/native.dart'; -import 'package:file_selector_ios/file_selector_ios.dart'; -// The locked plugin exposes its host fake through this generated API. Keep the -// real FileSelectorIOS UTI conversion between the app and this host boundary. -// ignore: implementation_imports -import 'package:file_selector_ios/src/messages.g.dart'; -import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/core/backup/backup_file_import_port.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/backup/backup_crypto.dart'; import 'package:on_time_front/core/backup/backup_service.dart'; @@ -24,9 +22,10 @@ import 'package:on_time_front/domain/entities/user_entity.dart'; import '../../helpers/sodium_test_loader.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); TestWidgetsFlutterBinding.ensureInitialized(); const password = 'synthetic portable backup password'; - late FileSelectorPlatform originalPlatform; late AppDatabase database; late BackupService service; late _Picker picker; @@ -44,14 +43,22 @@ void main() { }; setUp(() async { - originalPlatform = FileSelectorPlatform.instance; picker = _Picker(); - FileSelectorPlatform.instance = picker; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + NativeBackupFileImportPort.channel, + picker.handle, + ); database = AppDatabase.forTesting(NativeDatabase.memory()); service = BackupService( database, _Metadata(), NoopAlarmCleanup(), + stagingFactory: memoryRestoreStaging, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), ); await database.userDao.putUser( @@ -99,205 +106,132 @@ void main() { }); tearDown(() async { - FileSelectorPlatform.instance = originalPlatform; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(NativeBackupFileImportPort.channel, null); await database.close(); }); - - test( - 'app filter reaches real iOS host as public.data; cancel is null', - () async { - final host = _IOSHost(); - FileSelectorPlatform.instance = FileSelectorIOS(api: host); - - expect(await service.selectAndPreviewRestore(password), isNull); - expect(host.calls, 1); - expect(host.config!.utis, ['public.data']); - expect(host.config!.allowMultiSelection, isFalse); - expect(await snapshot(), before); - - // Cancellation does not poison a subsequent picker attempt. - expect(await service.selectAndPreviewRestore(password), isNull); - expect(host.calls, 2); - }, - ); - test( - 'previous extension-only filter fails before reaching iOS host', + 'cancel closes opaque native attempt without decrypting and permits another selection', () async { - final host = _IOSHost(); - final ios = FileSelectorIOS(api: host); - - await expectLater( - ios.openFile( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'OnTime Backup', - extensions: ['ontimebackup'], - mimeTypes: ['application/octet-stream'], - ), - ], - ), - throwsArgumentError, - ); - expect(host.calls, 0); - }, - ); - - test('app request preserves Android extension and MIME filters', () async { - expect(await service.selectAndPreviewRestore(password), isNull); - final group = picker.groups!.single; - expect(group.extensions, ['ontimebackup']); - expect(group.mimeTypes, ['application/octet-stream']); - expect(group.uniformTypeIdentifiers, ['public.data']); - expect(await snapshot(), before); - }); - - test( - 'cancel does not decrypt and a later valid selection can be previewed', - () async { - // Invalid password would fail if cancellation accidentally invoked crypto. expect(await service.selectAndPreviewRestore('short'), isNull); + expect(picker.closes, 1); expect(await snapshot(), before); - picker.file = XFile.fromData( - await service.createEncryptedBackup(password), - name: 'synthetic.ontimebackup', - ); - + picker.bytes = await service.createEncryptedBackup(password); final candidate = await service.selectAndPreviewRestore(password); expect(candidate!.preview.scheduleCount, 1); expect(candidate.preview.defaultPreparationStepCount, 1); expect(candidate.preview.sourceAppVersion, '1.0.0+1'); expect(await snapshot(), before); expect(picker.calls, 2); + expect(picker.closes, 2); + await candidate.dispose(); }, ); - test( - 'real iOS selected path is read and authenticated before preview', + 'selected opaque source authenticates before preview and closes at actual EOF', () async { - final directory = await Directory.systemTemp.createTemp('ontime-a03-'); - addTearDown(() => directory.delete(recursive: true)); - // A renamed valid backup is still judged by its contents, not extension. - final file = File('${directory.path}/synthetic-renamed.data'); - await file.writeAsBytes(await service.createEncryptedBackup(password)); - final host = _IOSHost()..paths = [file.path]; - FileSelectorPlatform.instance = FileSelectorIOS(api: host); - + picker.bytes = await service.createEncryptedBackup(password); + picker.shortReads = true; final candidate = await service.selectAndPreviewRestore(password); expect(candidate!.preview.scheduleCount, 1); - expect(host.config!.utis, ['public.data']); - expect(await snapshot(), before); - }, - ); - - test( - 'ordinary file with a backup extension is rejected without writes', - () async { - picker.file = XFile.fromData( - Uint8List.fromList('not an encrypted backup'.codeUnits), - name: 'synthetic.ontimebackup', - ); - - await expectLater( - service.selectAndPreviewRestore(password), - throwsFormatException, - ); + expect(picker.eofReads, 1); + expect(picker.closes, 1); expect(await snapshot(), before); + await candidate.dispose(); }, ); - - test('corrupted backup is rejected without writes', () async { - final encrypted = await service.createEncryptedBackup(password); - encrypted[encrypted.length - 1] ^= 1; - picker.file = XFile.fromData(encrypted, name: 'corrupt.ontimebackup'); - - await expectLater( - service.selectAndPreviewRestore(password), - throwsFormatException, - ); - expect(await snapshot(), before); - }); - - test('truncated backup is rejected without writes', () async { - final encrypted = await service.createEncryptedBackup(password); - picker.file = XFile.fromData( - Uint8List.sublistView(encrypted, 0, encrypted.length - 10), - name: 'truncated.ontimebackup', - ); - - await expectLater( - service.selectAndPreviewRestore(password), - throwsFormatException, - ); - expect(await snapshot(), before); - }); - - test('wrong password is rejected without writes', () async { - picker.file = XFile.fromData( - await service.createEncryptedBackup(password), - name: 'synthetic.ontimebackup', - ); - - await expectLater( - service.selectAndPreviewRestore('a different valid length password'), - throwsFormatException, + for (final mode in ['ordinary', 'corrupted', 'truncated', 'password']) { + test( + '$mode input never changes active data and source is closed', + () async { + final encrypted = await service.createEncryptedBackup(password); + picker.bytes = switch (mode) { + 'ordinary' => Uint8List.fromList('not a backup'.codeUnits), + 'truncated' => Uint8List.sublistView( + encrypted, + 0, + encrypted.length - 10, + ), + _ => encrypted, + }; + if (mode == 'corrupted') picker.bytes![picker.bytes!.length - 1] ^= 1; + await expectLater( + service.selectAndPreviewRestore( + mode == 'password' ? 'a different valid length password' : password, + ), + throwsFormatException, + ); + expect(await snapshot(), before); + expect(picker.closes, 1); + }, ); - expect(await snapshot(), before); - }); - + } test( - 'read failure preserves data and does not prevent another attempt', + 'provider read failure is typed, closes and permits retry without writes', () async { - picker.file = _UnreadableFile(); - + picker.bytes = Uint8List(1); + picker.readFailure = true; await expectLater( service.selectAndPreviewRestore(password), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.inputOutput, + ), + ), ); expect(await snapshot(), before); - picker.file = null; + expect(picker.closes, 1); + picker.bytes = null; + picker.readFailure = false; expect(await service.selectAndPreviewRestore(password), isNull); expect(picker.calls, 2); }, ); } -class _Picker extends FileSelectorPlatform { - XFile? file; - List? groups; - int calls = 0; - - @override - Future openFile({ - List? acceptedTypeGroups, - String? initialDirectory, - String? confirmButtonText, - }) async { - calls++; - groups = acceptedTypeGroups; - return file; - } -} - -class _IOSHost extends FileSelectorApi { - FileSelectorConfig? config; - List paths = []; - int calls = 0; - - @override - Future> openFile(FileSelectorConfig config) async { - calls++; - this.config = config; - return paths; - } -} - -class _UnreadableFile extends XFile { - _UnreadableFile() : super('synthetic-provider-file'); - - @override - Future readAsBytes() async { - throw const FileSystemException('Synthetic provider read failure'); +class _Picker { + Uint8List? bytes; + var calls = 0; + var closes = 0; + var eofReads = 0; + var offset = 0; + bool shortReads = false; + bool readFailure = false; + Future handle(MethodCall call) async { + final args = call.arguments as Map?; + if (call.method != 'begin') expect(args!['handle'], 'opaque-input'); + switch (call.method) { + case 'begin': + offset = 0; + return 'opaque-input'; + case 'pick': + calls++; + return bytes == null ? null : {'length': bytes!.length}; + case 'close': + closes++; + return true; + case 'read': + if (readFailure) { + throw PlatformException( + code: 'import_io', + message: 'private provider path', + ); + } + final max = args!['maxBytes'] as int; + expect(max, inInclusiveRange(1, 65536)); + if (offset == bytes!.length) { + eofReads++; + return {'bytes': Uint8List(0), 'eof': true}; + } + final end = (offset + (shortReads ? 7 : max)).clamp(0, bytes!.length); + final chunk = Uint8List.sublistView(bytes!, offset, end); + offset = end; + return {'bytes': chunk, 'eof': false}; + default: + fail('unexpected method'); + } } } diff --git a/test/core/backup/backup_restore_stale_contract_test.dart b/test/core/backup/backup_restore_stale_contract_test.dart index b9fc97df..6bb32758 100644 --- a/test/core/backup/backup_restore_stale_contract_test.dart +++ b/test/core/backup/backup_restore_stale_contract_test.dart @@ -1,3 +1,6 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/backup/backup_crypto.dart'; @@ -24,6 +27,8 @@ class _Cleanup extends NoopAlarmCleanup { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); test( 'preview followed by a durable edit never overwrites that edit', () async { @@ -44,6 +49,11 @@ void main() { _Metadata(), cleanup, operationGate: gate, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), ); const password = 'synthetic backup password'; diff --git a/test/core/backup/backup_retained_recurrence_test.dart b/test/core/backup/backup_retained_recurrence_test.dart new file mode 100644 index 00000000..911ab984 --- /dev/null +++ b/test/core/backup/backup_retained_recurrence_test.dart @@ -0,0 +1,383 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_export_snapshot.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/alarm_repository_impl.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; + +// Actual repository -> SQLite snapshot -> streamed validator -> SQLite restore. +// Ordinary SQLite is explicit here; this is not SQLCipher/native-provider proof. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late AppDatabase source; + late RecurringScheduleRepositoryImpl recurring; + var now = DateTime.utc(2026, 1, 1); + final start = DateTime.utc(2026, 1, 2, 10); + final preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'original-step', + preparationName: 'Prepare', + preparationTime: const Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + Future> rows(AppDatabase db) async => + (await db.scheduleDao.getScheduleList()) + .map((row) => row.toScheduleEntity()) + .toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + + Future create({int count = 4}) => recurring.create( + ScheduleEntity( + id: 'series', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Work', + scheduleTime: start, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ), + preparation, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: count, + ), + ); + + Future snapshot(AppDatabase db) async { + final snapshot = await BackupExportSnapshot.create( + db, + cutoff: now, + sourceAppVersion: 'a10-fixture', + sourcePlatform: 'android', + budget: BackupBudget(), + stagingFactory: () async { + final private = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(private, private.close); + }, + ); + addTearDown(snapshot.release); + return snapshot; + } + + ScheduleRepositoryImpl schedules(AppDatabase db) { + final value = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: _UnusedTimers(), + now: () => now, + ); + addTearDown(value.dispose); + return value; + } + + Future restore() async { + final frozen = await snapshot(source); + final checked = await BackupValidatedIngestion.validateOwnedSnapshot( + plaintext: frozen.plaintext(), + budget: BackupBudget(), + createStore: (budget) async => + BackupIngestionStore.memoryForTesting(budget), + nowUtc: now, + ); + addTearDown(checked.release); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: false); + await checked.validateReadBack(target, pendingCleanup: false); + return target; + } + + setUp(() async { + now = DateTime.utc(2026, 1, 1); + source = AppDatabase.forTesting(NativeDatabase.memory()); + recurring = RecurringScheduleRepositoryImpl(source, now: () => now); + await source.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + }); + tearDown(() => source.close()); + + test( + 'following end preserves a frozen tail through portable validation', + () async { + await create(); + final before = await rows(source); + now = DateTime.utc(2026, 1, 3, 9, 55); + await schedules(source).startSchedule(before[1].id, startedAt: now); + await recurring.delete(before[1], RecurringEditScope.following); + final target = await restore(); + final restored = await rows(target); + expect(restored.map((s) => s.id), before.take(2).map((s) => s.id)); + expect(restored.last.preparationFrozen, isTrue); + expect(restored.last.startedAt?.toUtc(), now); + expect(restored.last.retainedRecurringReference, isTrue); + expect(restored.last.recurringSegmentId, before[1].recurringSegmentId); + expect( + restored.last.preparationDefinitionId, + before[1].preparationDefinitionId, + ); + expect(restored.last.isStarted, isFalse); + final targetSchedules = schedules(target); + final revision = + (await target + .customSelect( + "SELECT data_revision FROM users WHERE id='local-profile'", + ) + .getSingle()) + .read('data_revision'); + await expectLater( + targetSchedules.startSchedule(restored.last.id, startedAt: now), + throwsA(isA()), + ); + expect( + (await target + .customSelect( + "SELECT data_revision FROM users WHERE id='local-profile'", + ) + .getSingle()) + .read('data_revision'), + revision, + ); + final alarmCandidates = + await AlarmRepositoryImpl( + database: target, + scheduleRepository: targetSchedules, + preparationRepository: _UnusedPreparation(), + ).getAlarmWindow( + now.subtract(const Duration(days: 2)), + DateTime.utc(2028), + ); + expect(alarmCandidates.any((s) => s.id == restored.last.id), isFalse); + + await RecurringScheduleRepositoryImpl( + target, + now: () => now, + ).materialize(start, DateTime.utc(2028)); + expect((await rows(target)).map((s) => s.id), restored.map((s) => s.id)); + }, + ); + + test( + 'following edit keeps deleted-slot tombstone beyond old generation end', + () async { + await create(count: 10); + final before = await rows(source); + await recurring.delete( + before.firstWhere((s) => s.scheduleTime.day == 6), + RecurringEditScope.occurrence, + ); + now = DateTime.utc(2026, 1, 5); + final anchor = before.firstWhere((s) => s.scheduleTime.day == 5); + await recurring.updateFollowing( + anchor, + anchor, + preparation, + RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: DateTime.utc(2026, 1, 5, 10), + timeZoneId: 'UTC', + weekdays: {1, 3, 5}, + count: 10, + ), + ); + final exclusions = await source + .select(source.recurringScheduleExclusions) + .get(); + expect(exclusions, isNotEmpty); + final target = await restore(); + final restoredExclusions = await target + .select(target.recurringScheduleExclusions) + .get(); + expect( + restoredExclusions.map((e) => e.toJson()), + exclusions.map((e) => e.toJson()), + ); + final retained = await rows(target); + await RecurringScheduleRepositoryImpl( + target, + now: () => now, + ).materialize(start, DateTime.utc(2028)); + expect((await rows(target)).map((s) => s.id), retained.map((s) => s.id)); + final exported = await snapshot(target); + final portable = jsonDecode( + utf8.decode(await exported.plaintext().expand((b) => b).toList()), + ); + expect(portable['recurring']['exclusions'], isNotEmpty); + }, + ); + + for (final corruption in [ + 'future-unprotected', + 'before-from', + 'count-overflow', + 'wrong-weekday', + 'dangling-definition', + 'wrong-owner', + 'duplicate-slot', + 'duplicate-ordinal', + 'ordinal-order', + 'calendar-ordinal-overflow', + 'invalid-calendar', + ]) { + test( + 'retained reference rejects $corruption without changing source', + () async { + await create(); + final initial = await rows(source); + now = DateTime.utc(2026, 1, 3, 9, 55); + await schedules(source).startSchedule(initial[1].id, startedAt: now); + await recurring.delete(initial[1], RecurringEditScope.following); + final frozen = await snapshot(source); + final payload = + jsonDecode( + utf8.decode( + await frozen.plaintext().expand((bytes) => bytes).toList(), + ), + ) + as Map; + final schedule = (payload['schedules'] as List) + .cast>() + .firstWhere((s) => s['id'] == initial[1].id); + final segment = (payload['recurring']['segments'] as List) + .cast>() + .firstWhere((s) => s['id'] == schedule['recurringSegmentId']); + switch (corruption) { + case 'future-unprotected': + schedule['preparationFrozen'] = false; + schedule['startedAt'] = null; + case 'before-from': + schedule['recurringSlotKey'] = '2025-12-31T10:00:00.000Z'; + case 'count-overflow': + schedule['recurringOrdinal'] = 999; + case 'wrong-weekday': + final rule = + jsonDecode(segment['ruleJson'] as String) + as Map; + rule['frequency'] = 'weekly'; + rule['weekdays'] = [1]; + segment['ruleJson'] = jsonEncode(rule); + case 'dangling-definition': + schedule['preparationDefinitionId'] = 'not-owned'; + case 'wrong-owner': + final otherId = (payload['schedules'] as List) + .cast>() + .firstWhere((s) => s['id'] != schedule['id'])['id']; + final definition = + (payload['recurring']['definitions'] as List).first + as Map; + payload['recurring']['definitions'].add({ + ...definition, + 'id': 'other-definition', + 'scope': 'occurrence', + 'ownerId': otherId, + }); + final step = + (payload['recurring']['steps'] as List).first + as Map; + payload['recurring']['steps'].add({ + ...step, + 'id': 'other-step', + 'definitionId': 'other-definition', + }); + schedule['preparationDefinitionId'] = 'other-definition'; + schedule['recurringOverrides'] = 'preparation'; + case 'duplicate-slot': + payload['recurring']['exclusions'].add({ + 'segmentId': schedule['recurringSegmentId'], + 'slotKey': schedule['recurringSlotKey'], + 'ordinal': schedule['recurringOrdinal'], + }); + case 'duplicate-ordinal': + payload['recurring']['exclusions'].add({ + 'segmentId': schedule['recurringSegmentId'], + 'slotKey': '2026-01-04T10:00:00.000Z', + 'ordinal': schedule['recurringOrdinal'], + }); + case 'ordinal-order': + final other = (payload['schedules'] as List) + .cast>() + .firstWhere((s) => s['id'] != schedule['id']); + other['recurringOrdinal'] = 2; + schedule['recurringOrdinal'] = 1; + case 'calendar-ordinal-overflow': + schedule['recurringOrdinal'] = 4; + case 'invalid-calendar': + schedule['recurringSlotKey'] = '2026-02-30T10:00:00.000Z'; + } + await expectLater( + BackupValidatedIngestion.validateOwnedSnapshot( + plaintext: Stream.value(utf8.encode(jsonEncode(payload))), + budget: BackupBudget(), + createStore: (budget) async => + BackupIngestionStore.memoryForTesting(budget), + nowUtc: now, + ), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.dataInvariant, + ), + ), + ); + expect( + (await rows(source)).map((s) => s.id), + initial.take(2).map((s) => s.id), + ); + expect((await rows(source)).last.preparationFrozen, isTrue); + }, + ); + } +} + +class _UnusedTimers implements TimedPreparationRepository { + @override + Future clearTimedPreparation(String id) async {} + @override + Future getTimedPreparationSnapshot( + String id, + ) async => null; + @override + Future saveTimedPreparationSnapshot( + String id, + TimedPreparationSnapshotEntity value, + ) async {} +} + +class _UnusedPreparation implements PreparationRepository { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/core/backup/backup_service_test.dart b/test/core/backup/backup_service_test.dart index bef11cee..24642fbc 100644 --- a/test/core/backup/backup_service_test.dart +++ b/test/core/backup/backup_service_test.dart @@ -1,3 +1,7 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import '../../helpers/noop_alarm_cleanup.dart'; import 'dart:async'; import 'dart:convert'; @@ -25,6 +29,8 @@ import 'package:on_time_front/domain/entities/user_entity.dart'; import '../../helpers/sodium_test_loader.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); const password = 'portable backup password'; late AppDatabase database; late BackupService service; @@ -39,6 +45,11 @@ void main() { database, _MetadataProvider(), NoopAlarmCleanup(), + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), exportPort: exportPort, operationGate: gate, @@ -83,11 +94,16 @@ void main() { (await database.select(database.users).getSingle()).dataRevision; final pending = service.exportToUserSelectedFile(password); await exportPort.opened.future; - final candidate = await service.previewEncryptedBackup( - exportPort.bytes!, - password, - ); - expect(candidate.preview.scheduleCount, 1); + final decoded = + jsonDecode( + utf8.decode( + await BackupCrypto( + sodiumLoader: loadSodiumForTest, + ).decrypt(container: exportPort.bytes!, password: password), + ), + ) + as Map; + expect((decoded['schedules'] as List).length, 1); expect(exportPort.name, endsWith('.ontimebackup')); await database.userDao.updateSpareTime( 'local-profile', @@ -141,32 +157,34 @@ void main() { ); test( - 'second export, restore, and destructive operation reject while picker is open', + 'backup resource lease rejects another backup while ordinary DB writes continue', () async { - final candidate = await service.previewEncryptedBackup( - await service.createEncryptedBackup(password), - password, - ); + final bytes = await service.createEncryptedBackup(password); final pending = service.exportToUserSelectedFile(password); await exportPort.opened.future; await expectLater( - service.exportToUserSelectedFile('different backup password'), - throwsA(isA()), + service.exportToUserSelectedFile(password), + throwsA(isA()), ); await expectLater( - service.applyRestore(candidate), - throwsA(isA()), - ); - await expectLater( - gate.run(() async { - fail('destructive operation entered'); - }, replacesData: true), - throwsA(isA()), + service.previewEncryptedBackup(bytes, password), + throwsA(isA()), ); + await gate.run(() async { + await database.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 12), + ); + }); expect(exportPort.calls, 1); exportPort.completion.complete(BackupFileExportReceipt.cancelled); await pending; - await service.applyRestore(candidate); + final candidate = await service.previewEncryptedBackup(bytes, password); + await expectLater( + service.createEncryptedBackup(password), + throwsA(isA()), + ); + await candidate.dispose(); }, ); @@ -266,6 +284,15 @@ void main() { .map((r) => r.toScheduleEntity()) .where((s) => s.isRecurring) .toList(); + final frozen = generated.first; + final frozenAt = DateTime.utc(2030, 1, 1, 23); + final ownedBefore = await recurring.getPreparation( + frozen.preparationDefinitionId!, + ); + await database.customStatement( + 'UPDATE schedules SET started_at=?, preparation_frozen=1, is_started=1 WHERE id=?', + [frozenAt.millisecondsSinceEpoch ~/ 1000, frozen.id], + ); final deleted = generated[1]; await recurring.delete(deleted, RecurringEditScope.occurrence); final oldStore = @@ -314,6 +341,16 @@ void main() { .toList(); expect(restored, hasLength(2)); expect(restored.any((s) => s.id == deleted.id), isFalse); + final frozenAfter = restored.singleWhere((s) => s.id == frozen.id); + expect(frozenAfter.startedAt!.toUtc(), frozenAt); + expect(frozenAfter.preparationFrozen, isTrue); + expect(frozenAfter.isStarted, isFalse); + expect(frozenAfter.requiresStartConfirmation, isTrue); + expect( + await recurring.getPreparation(frozenAfter.preparationDefinitionId!), + ownedBefore, + ); + expect( (await recurring.getPreparation( restored.first.preparationDefinitionId!, @@ -429,12 +466,17 @@ class _ExportPort implements BackupFileExportPort { int calls = 0; @override - Future export({ - required Uint8List encryptedBytes, + Future exportStream({ + required Stream> encrypted, required String suggestedName, - }) { + BackupProcessingLease? lease, + }) async { calls++; - bytes = encryptedBytes; + final output = BytesBuilder(copy: false); + await for (final chunk in encrypted) { + output.add(chunk); + } + bytes = output.takeBytes(); name = suggestedName; opened.complete(); return completion.future; diff --git a/test/core/backup/backup_spare_time_boundary_test.dart b/test/core/backup/backup_spare_time_boundary_test.dart new file mode 100644 index 00000000..01a51978 --- /dev/null +++ b/test/core/backup/backup_spare_time_boundary_test.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'backup_validated_ingestion_test.dart' show validate; +import 'backup_validation_contract_test.dart' show validBackup; + +void main() { + for (final spare in [null, 60]) { + test( + 'year-one appointment spare=$spare uses its own lead without profile fallback overflow', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['profile']['spareTimeMinutes'] = 0x7fffffffffffffff ~/ 60000000; + input['schedules'][0].addAll({ + 'civilTime': '0001-01-01T00:00:00.000', + 'timeZoneId': 'UTC', + 'occurrenceOffsetSeconds': 0, + 'spareTimeMinutes': spare, + }); + if (spare != null) { + await expectLater( + validate(input), + throwsA( + isA().having( + (e) => e.kind, + 'genuine appointment lead underflow', + BackupFailureKind.dataInvariant, + ), + ), + ); + return; + } + final data = await validate(input); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await data.materialize(db, pendingCleanup: false); + await data.validateReadBack(db, pendingCleanup: false); + final schedule = await db.select(db.schedules).getSingle(); + expect(schedule.scheduleSpareTime, isNull); + expect(schedule.scheduleTime, DateTime.utc(1)); + expect( + (await db.select(db.users).getSingle()).spareTime, + 0x7fffffffffffffff ~/ 60000000, + ); + }, + ); + } + test( + 'profile own duration limit remains enforced even if no appointment inherits it', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['profile']['spareTimeMinutes'] = + (0x7fffffffffffffff ~/ 60000000) + 1; + input['schedules'][0]['spareTimeMinutes'] = null; + await expectLater( + validate(input), + throwsA( + isA().having( + (e) => e.kind, + 'profile duration limit', + BackupFailureKind.dataInvariant, + ), + ), + ); + }, + ); +} diff --git a/test/core/backup/backup_time_review_service_test.dart b/test/core/backup/backup_time_review_service_test.dart new file mode 100644 index 00000000..186d3642 --- /dev/null +++ b/test/core/backup/backup_time_review_service_test.dart @@ -0,0 +1,548 @@ +// Real authenticated ciphertext and service ownership with explicit in-memory +// SQLite ingestion/staging adapters. This is not SQLCipher or native UI proof. +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'backup_validation_contract_test.dart' show validBackup; + +const _password = 'portable review password'; +Map _input() { + final value = jsonDecode(jsonEncode(validBackup())) as Map; + value['schedules'][0].addAll({ + 'civilTime': '2031-01-02T09:00:00.123456', + 'timeZoneId': 'Old/Removed_Service_Zone', + 'occurrenceOffsetSeconds': 0, + }); + return value; +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1.0.0', buildNumber: '1'); +} + +class _AlarmCleanup extends NoopAlarmCleanup { + int calls = 0; + Future Function()? onDataReplacement; + @override + Future call() async { + calls++; + } + + @override + Future forDataReplacement() async { + calls++; + await onDataReplacement?.call(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late AppDatabase active; + late BackupService service; + late BackupCrypto crypto; + late BackupProcessingOwner owner; + late LocalDataOperationGate gate; + late _AlarmCleanup alarms; + late DateTime now; + var stagingCreates = 0; + var stagingReleases = 0; + AppDatabase? lastStaging; + Completer? stagingEntered; + Completer? allowStaging; + final stores = []; + final selections = []; + + Future activeSnapshot() async { + final tables = await active + .customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .get(); + final result = >{}; + for (final table in tables) { + final name = table.read('name'); + final quoted = name.replaceAll('"', '""'); + final rows = await active.customSelect('SELECT * FROM "$quoted"').get(); + result[name] = rows.map((row) => jsonEncode(row.data)).toList()..sort(); + } + return jsonEncode(result); + } + + Future encrypted([Map? value]) => crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(value ?? _input()))), + password: _password, + ); + + Future beginReview([Uint8List? ciphertext]) async { + final selected = await service.reviewEncryptedBackup( + ciphertext ?? await encrypted(), + _password, + ); + selections.add(selected); + expect(selected, isA()); + return selected as BackupTimeReviewInput; + } + + Future fieldReview( + BackupTimeReviewInput review, { + String civil = '2031-01-02T09:00:00.123456', + }) async { + final issue = (await review.issues()).items.single; + final summary = review.summary; + return review.review( + BackupTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + civil: CivilDateTime.parse(civil), + zone: 'UTC', + ), + ); + } + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + now = DateTime.utc(2030, 1, 1); + stagingCreates = 0; + stagingReleases = 0; + lastStaging = null; + stagingEntered = null; + allowStaging = null; + stores.clear(); + selections.clear(); + active = AppDatabase.forTesting(NativeDatabase.memory()); + await active.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 7), + note: 'Keep active data', + isOnboardingCompleted: true, + ), + ); + owner = BackupProcessingOwner(); + gate = LocalDataOperationGate(); + alarms = _AlarmCleanup(); + crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + service = BackupService( + active, + _Metadata(), + alarms, + now: () => now, + crypto: crypto, + processingOwner: owner, + operationGate: gate, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: () async {}, + ingestionFactory: (budget) async { + final store = BackupIngestionStore.memoryForTesting(budget); + stores.add(store); + return store; + }, + stagingFactory: () async { + stagingCreates++; + stagingEntered?.complete(); + if (allowStaging != null) await allowStaging!.future; + final database = AppDatabase.forTesting(NativeDatabase.memory()); + lastStaging = database; + return RestoreStaging(database, () async { + stagingReleases++; + await database.close(); + }); + }, + ); + }); + tearDown(() async { + for (final selection in selections.reversed) { + await selection.dispose(); + } + for (final store in stores) { + await store.release(); + } + await active.close(); + gate.dispose(); + }); + + test( + 'authenticated time review retains one lease and changes no active data or alarms', + () async { + final before = await activeSnapshot(); + final review = await beginReview(); + expect(review.summary.independentStructureChecked, isTrue); + expect(review.summary.issueCount, 1); + expect(review, isNot(isA())); + expect(owner.active, isNotNull); + expect(stagingCreates, 0); + expect(alarms.calls, 0); + expect(gate.generation, 0); + expect(await activeSnapshot(), before); + await expectLater( + service.reviewEncryptedBackup(await encrypted(), _password), + throwsA( + isA().having( + (e) => e.failure, + 'busy lease', + DataOperationFailure.busy, + ), + ), + ); + await review.dispose(); + expect(owner.active, isNull); + expect(await activeSnapshot(), before); + }, + ); + + for (final corruption in ['wrong password', 'final tag', 'trailing byte']) { + test('$corruption cannot create a review owner or ready staging', () async { + final before = await activeSnapshot(); + var bytes = await encrypted(); + if (corruption == 'final tag') { + bytes = Uint8List.fromList(bytes); + bytes[bytes.length - 1] ^= 1; + } else if (corruption == 'trailing byte') { + bytes = Uint8List.fromList([...bytes, 0]); + } + await expectLater( + service.reviewEncryptedBackup( + bytes, + corruption == 'wrong password' + ? 'incorrect review password' + : _password, + ), + throwsA(isA()), + ); + expect(owner.active, isNull); + expect(stagingCreates, 0); + expect(alarms.calls, 0); + expect(await activeSnapshot(), before); + }); + } + + test( + 'a time issue cannot hide an independent duplicate schedule identity', + () async { + final before = await activeSnapshot(); + final input = _input(); + input['schedules'].add(Map.from(input['schedules'][0])); + await expectLater( + service.reviewEncryptedBackup(await encrypted(input), _password), + throwsA( + isA().having( + (e) => e.kind, + 'hard graph failure', + BackupFailureKind.dataInvariant, + ), + ), + ); + expect(owner.active, isNull); + expect(stagingCreates, 0); + expect(await activeSnapshot(), before); + }, + ); + + test( + 'accepted edit retires its old review and transfers the lease only to fresh ready input', + () async { + final before = await activeSnapshot(); + final ciphertext = await encrypted(); + final originalCiphertext = Uint8List.fromList(ciphertext); + final review = await beginReview(ciphertext); + final lease = owner.active; + final originalRevision = review.summary.revision; + final field = await fieldReview(review); + final choice = BackupTimeChoice(review: field, offsetSeconds: 0); + expect(field.choices.single.offsetSeconds, 0); + await review.choose(choice); + final acceptedRevision = review.summary.revision; + expect(acceptedRevision, greaterThan(originalRevision)); + await expectLater( + review.choose(choice), + throwsA(isA()), + ); + expect(review.summary.revision, acceptedRevision); + expect(stagingCreates, 0); + final ready = await review.revalidate(); + selections.add(ready); + expect(ready, isA()); + expect(stagingCreates, 1); + expect(owner.active, same(lease)); + await review.dispose(); + expect(owner.active, same(lease)); + await expectLater( + () => review.revalidate(), + throwsA(isA()), + ); + final row = await lastStaging!.select(lastStaging!.schedules).getSingle(); + expect(row.timeZoneId, 'UTC'); + expect(row.occurrenceOffsetSeconds, 0); + expect(row.isStarted, isFalse); + expect( + (await lastStaging! + .customSelect('SELECT schedule_time FROM schedules') + .getSingle()) + .read('schedule_time'), + '2031-01-02T09:00:00.123456', + ); + expect(ciphertext, originalCiphertext); + expect((ready as BackupRestoreInput).preview.scheduleCount, 1); + expect(await activeSnapshot(), before); + expect(alarms.calls, 0); + await ready.dispose(); + expect(owner.active, isNull); + expect(stagingReleases, 1); + }, + ); + + test( + 'clock rewind retires a field confirmation instead of silently applying it', + () async { + final review = await beginReview(); + final field = await fieldReview(review); + final revision = review.summary.revision; + now = now.subtract(const Duration(seconds: 1)); + await expectLater( + review.choose(BackupTimeChoice(review: field, offsetSeconds: 0)), + throwsA(isA()), + ); + expect(review.summary.revision, revision); + expect(stagingCreates, 0); + }, + ); + + test( + 'a changed explicit offset requires review while the strict entry rejects it', + () async { + final input = _input(); + input['schedules'][0]['timeZoneId'] = 'UTC'; + input['schedules'][0]['occurrenceOffsetSeconds'] = 3600; + final bytes = await encrypted(input); + final before = await activeSnapshot(); + await expectLater( + service.previewEncryptedBackup(bytes, _password), + throwsA( + isA().having( + (e) => e.kind, + 'explicit review required', + BackupFailureKind.timeZoneChoiceRequired, + ), + ), + ); + expect(owner.active, isNull); + expect(stagingCreates, 0); + final review = await beginReview(bytes); + final issue = (await review.issues()).items.single; + expect(issue.reason, BackupTimeIssueReason.changedOffset); + expect(issue.selectedOffsetSeconds, 3600); + final field = await fieldReview(review); + expect( + field.previousInstantUtc, + DateTime.utc(2031, 1, 2, 8, 0, 0, 123, 456), + ); + expect( + field.choices.single.instantUtc, + DateTime.utc(2031, 1, 2, 9, 0, 0, 123, 456), + ); + expect(stagingCreates, 0); + await review.choose(BackupTimeChoice(review: field, offsetSeconds: 0)); + final ready = await review.revalidate(); + selections.add(ready); + expect(ready, isA()); + expect( + (await lastStaging!.select(lastStaging!.schedules).getSingle()) + .occurrenceOffsetSeconds, + 0, + ); + expect(await activeSnapshot(), before); + }, + ); + + test( + 'a unique future null offset stays null without manufacturing historical selection', + () async { + final input = _input(); + input['schedules'][0]['timeZoneId'] = 'UTC'; + input['schedules'][0]['occurrenceOffsetSeconds'] = null; + final selected = await service.reviewEncryptedBackup( + await encrypted(input), + _password, + ); + selections.add(selected); + expect(selected, isA()); + final row = await lastStaging!.select(lastStaging!.schedules).getSingle(); + expect(row.occurrenceOffsetSeconds, isNull); + expect(row.startedAt, isNull); + expect(row.isStarted, isFalse); + expect((selected as BackupRestoreInput).preview.timezoneChangeCount, 0); + }, + ); + + for (final change in ['clock rewind', 'target passed']) { + test( + '$change during staging creation cannot publish stale ready authority', + () async { + final before = await activeSnapshot(); + final review = await beginReview(); + await review.choose( + BackupTimeChoice(review: await fieldReview(review), offsetSeconds: 0), + ); + stagingEntered = Completer(); + allowStaging = Completer(); + final pending = review.revalidate().then( + (value) => value, + onError: (Object error) => error, + ); + await stagingEntered!.future; + now = change == 'clock rewind' + ? now.subtract(const Duration(seconds: 1)) + : DateTime.utc(2032); + allowStaging!.complete(); + final outcome = await pending; + if (outcome is BackupRestoreSelection) selections.add(outcome); + expect( + outcome, + anyOf( + isA(), + isA().having( + (e) => e.failure, + 'stale preview', + DataOperationFailure.stalePreview, + ), + ), + ); + expect(stagingReleases, 1); + expect(await activeSnapshot(), before); + expect(alarms.calls, 0); + await review.dispose(); + expect(owner.active, isNull); + }, + ); + + test( + '$change after ready is rejected before replacement generation or alarm cleanup', + () async { + final before = await activeSnapshot(); + final review = await beginReview(); + await review.choose( + BackupTimeChoice(review: await fieldReview(review), offsetSeconds: 0), + ); + final selected = await review.revalidate(); + selections.add(selected); + expect(selected, isA()); + now = change == 'clock rewind' + ? now.subtract(const Duration(seconds: 1)) + : DateTime.utc(2032); + await expectLater( + service.applyRestoreWithReceipt(selected as BackupRestoreCandidate), + throwsA( + isA().having( + (e) => e.failure, + 'stale preview', + DataOperationFailure.stalePreview, + ), + ), + ); + expect(gate.generation, 0); + expect(alarms.calls, 0); + expect(await activeSnapshot(), before); + }, + ); + } + + test( + 'expiry during alarm cleanup preserves active data and reports the claimed cleanup generation', + () async { + final before = await activeSnapshot(); + final review = await beginReview(); + await review.choose( + BackupTimeChoice(review: await fieldReview(review), offsetSeconds: 0), + ); + final selected = await review.revalidate(); + selections.add(selected); + final entered = Completer(); + final resume = Completer(); + alarms.onDataReplacement = () async { + entered.complete(); + await resume.future; + }; + final pending = service + .applyRestoreWithReceipt(selected as BackupRestoreCandidate) + .then((value) => value, onError: (Object error) => error); + await entered.future; + now = DateTime.utc(2032); + resume.complete(); + final outcome = await pending; + expect( + outcome, + isA() + .having( + (e) => e.failure, + 'stale preview', + DataOperationFailure.stalePreview, + ) + .having((e) => e.followUpPending, 'cleanup already claimed', isTrue) + .having((e) => e.generation, 'claimed generation', 1), + ); + expect(gate.generation, 1); + expect(alarms.calls, 1); + expect(await activeSnapshot(), before); + }, + ); + + test( + 'discard during staging creation cannot return a usable late candidate', + () async { + final before = await activeSnapshot(); + final review = await beginReview(); + await review.choose( + BackupTimeChoice(review: await fieldReview(review), offsetSeconds: 0), + ); + stagingEntered = Completer(); + allowStaging = Completer(); + final pending = review.revalidate().then( + (value) => value, + onError: (Object error) => error, + ); + await stagingEntered!.future; + final disposal = review.dispose(); + allowStaging!.complete(); + final outcome = await pending; + await disposal; + expect( + outcome, + anyOf( + isA(), + isA().having( + (e) => e.kind, + 'cancelled owner', + BackupFailureKind.userCancelled, + ), + ), + ); + expect(owner.active, isNull); + expect(stagingReleases, 1); + expect(await activeSnapshot(), before); + expect(alarms.calls, 0); + }, + ); +} diff --git a/test/core/backup/backup_unknown_history_test.dart b/test/core/backup/backup_unknown_history_test.dart new file mode 100644 index 00000000..439ca03c --- /dev/null +++ b/test/core/backup/backup_unknown_history_test.dart @@ -0,0 +1,171 @@ +// Owned plaintext snapshot adapter + actual SQLite materialization/readback. +// This does not prove ciphertext authentication, SQLCipher, service or OS UI. +import 'dart:convert'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'backup_validation_contract_test.dart' show validBackup; + +const _removedZone = 'Old/Removed_Test_Zone'; +final _now = DateTime.utc(2026, 9, 24); +Map _input() { + final input = jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0].addAll({ + 'civilTime': '2020-01-01T09:00:00.123456', + 'timeZoneId': _removedZone, + 'occurrenceOffsetSeconds': 32400, + }); + return input; +} + +Future _validate(Map input) => + BackupValidatedIngestion.validateOwnedSnapshot( + plaintext: Stream.value(utf8.encode(jsonEncode(input))), + budget: BackupBudget(), + createStore: (budget) async => + BackupIngestionStore.memoryForTesting(budget), + nowUtc: _now, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final scenario in [ + 'past selected', + 'past null', + 'completed', + 'protected start', + ]) { + test( + 'owned snapshot preserves unknown-zone $scenario without granting a live run', + () async { + final input = _input(); + final source = input['schedules'][0] as Map; + if (scenario == 'past null') source['occurrenceOffsetSeconds'] = null; + if (scenario == 'completed' || scenario == 'protected start') { + // Protected facts can precede the originally future commitment. This + // must not be misclassified as an unstarted future unknown appointment. + source['civilTime'] = '2030-01-01T09:00:00.123456'; + source['startedAt'] = '2026-09-23T00:00:00Z'; + source['preparationFrozen'] = true; + } + if (scenario == 'completed') { + source['doneStatus'] = 'normalEnd'; + source['finishedAt'] = '2026-09-23T00:01:00Z'; + } + final original = jsonEncode(input); + final checked = await _validate(input); + addTearDown(checked.release); + expect(checked.preview.scheduleCount, 1); + expect(checked.timezoneChangeCount, 0); + expect(checked.preview.uncertainHistoryCount, 1); + final notice = checked.preview.uncertainHistoryExamples.single; + expect(notice.zone, _removedZone); + expect( + notice.reason, + scenario == 'protected start' + ? BackupHistoryUncertaintyReason.protectedStartZone + : BackupHistoryUncertaintyReason.unavailableHistoricalZone, + ); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: true); + await checked.validateReadBack(target, pendingCleanup: true); + final stored = await target.select(target.schedules).getSingle(); + expect(stored.id, source['id']); + expect(stored.timeZoneId, _removedZone); + expect( + stored.occurrenceOffsetSeconds, + source['occurrenceOffsetSeconds'], + ); + expect( + (await target + .customSelect('SELECT schedule_time FROM schedules') + .getSingle()) + .read('schedule_time'), + source['civilTime'], + ); + expect(stored.isStarted, isFalse); + expect(stored.preparationFrozen, source['preparationFrozen'] ?? false); + expect( + stored.startedAt?.toUtc(), + source['startedAt'] == null + ? null + : DateTime.parse(source['startedAt'] as String), + ); + expect( + stored.finishedAt?.toUtc(), + source['finishedAt'] == null + ? null + : DateTime.parse(source['finishedAt'] as String), + ); + expect(stored.doneStatus, source['doneStatus']); + expect( + stored.requiresStartConfirmation, + source['doneStatus'] == 'notEnded', + ); + expect(jsonEncode(input), original); + }, + ); + } + for (final offset in [null, 32400]) { + test( + 'future unstarted unknown zone remains rejected with offset $offset', + () async { + final input = _input(); + input['schedules'][0]['civilTime'] = '2030-01-01T09:00:00'; + input['schedules'][0]['occurrenceOffsetSeconds'] = offset; + await expectLater( + _validate(input), + throwsA(isA()), + ); + }, + ); + } + + final malformed = )>{ + 'zone scalar type': (input) => input['schedules'][0]['timeZoneId'] = 42, + 'oversized zone': (input) => + input['schedules'][0]['timeZoneId'] = 'z' * 513, + 'invalid civil calendar': (input) => + input['schedules'][0]['civilTime'] = '2020-02-30T09:00:00', + 'invalid selected offset': (input) => + input['schedules'][0]['occurrenceOffsetSeconds'] = 86401, + 'future frozen without start': (input) { + input['schedules'][0]['civilTime'] = '2030-01-01T09:00:00'; + input['schedules'][0]['preparationFrozen'] = true; + }, + 'offsetless start instant': (input) { + input['schedules'][0]['startedAt'] = '2020-01-01T00:00:00'; + input['schedules'][0]['preparationFrozen'] = true; + }, + 'offsetless finish instant': (input) { + input['schedules'][0]['finishedAt'] = '2020-01-01T00:01:00'; + input['schedules'][0]['doneStatus'] = 'normalEnd'; + }, + 'orphan preparation': (input) => + input['schedulePreparations']['not-a-schedule'] = [], + 'duplicate schedule identity': (input) => input['schedules'].add( + Map.from(input['schedules'][0]), + ), + }; + for (final entry in malformed.entries) { + test('unknown history does not excuse ${entry.key}', () async { + // The known-zone control ensures the structural rejection is independently + // real, rather than passing only because the current unknown-zone gate fails. + for (final zone in ['UTC', _removedZone]) { + final input = _input(); + input['schedules'][0]['timeZoneId'] = zone; + if (zone == 'UTC') input['schedules'][0]['occurrenceOffsetSeconds'] = 0; + entry.value(input); + final original = jsonEncode(input); + await expectLater(_validate(input), throwsFormatException); + expect(jsonEncode(input), original); + } + }); + } +} diff --git a/test/core/backup/backup_unknown_recurrence_test.dart b/test/core/backup/backup_unknown_recurrence_test.dart new file mode 100644 index 00000000..583eb4f5 --- /dev/null +++ b/test/core/backup/backup_unknown_recurrence_test.dart @@ -0,0 +1,212 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +import 'backup_validated_ingestion_test.dart' show recurringBackup; + +const zone = 'Old/Removed_Recurring_Zone'; +final now = DateTime.utc(2026, 9, 24); + +Map input({bool closed = false}) { + final value = recurringBackup(); + value['schedules'][0]['timeZoneId'] = zone; + final segment = value['recurring']['segments'][0]; + final rule = jsonDecode(segment['ruleJson'] as String); + rule['zone'] = zone; + if (closed) { + segment['beforeSlot'] = '2025-01-03T09:00:00'; + } else { + rule['until'] = '2025-01-03T00:00:00'; + } + segment['ruleJson'] = jsonEncode(rule); + final prototype = jsonDecode(segment['scheduleJson'] as String); + prototype['zone'] = zone; + segment['scheduleJson'] = jsonEncode(prototype); + return value; +} + +Future validate( + Map value, { + DateTime? at, +}) => BackupValidatedIngestion.validateOwnedSnapshot( + plaintext: Stream.value(utf8.encode(jsonEncode(value))), + budget: BackupBudget(), + createStore: (budget) async => BackupIngestionStore.memoryForTesting(budget), + nowUtc: at ?? now, +); + +void changeRule( + Map value, + void Function(Map) edit, +) { + final segment = value['recurring']['segments'][0]; + final rule = + jsonDecode(segment['ruleJson'] as String) as Map; + edit(rule); + segment['ruleJson'] = jsonEncode(rule); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final closed in [false, true]) { + test( + 'unknown ${closed ? 'exclusive bound' : 'inclusive until'} proof changes exactly at its latest possible instant', + () async { + final value = input(closed: closed); + final boundary = closed + ? DateTime.utc(2025, 1, 4, 9) + : DateTime.utc(2025, 1, 5); + await expectLater( + validate( + value, + at: boundary.subtract(const Duration(microseconds: 1)), + ), + throwsA( + isA().having( + (e) => e.kind, + 'not yet proved', + BackupFailureKind.timeZoneChoiceRequired, + ), + ), + ); + final checked = await validate(value, at: boundary); + addTearDown(checked.release); + expect(checked.preview.timezoneChangeCount, 0); + expect(checked.preview.uncertainHistoryCount, greaterThan(0)); + }, + ); + } + for (final closed in [false, true]) { + test( + 'unknown historical ${closed ? 'closed retained tail' : 'inclusive until'} preserves source relations', + () async { + final value = input(closed: closed); + final original = jsonEncode(value); + final checked = await validate(value); + addTearDown(checked.release); + expect(checked.timezoneChangeCount, 0); + expect( + checked.preview.uncertainHistoryExamples.map((e) => e.reason), + contains(BackupHistoryUncertaintyReason.historicalOrdinal), + ); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: true); + await checked.validateReadBack(target, pendingCleanup: true); + final segment = await target + .select(target.recurringScheduleSegments) + .getSingle(); + expect(segment.ruleJson, value['recurring']['segments'][0]['ruleJson']); + expect( + segment.scheduleJson, + value['recurring']['segments'][0]['scheduleJson'], + ); + expect( + segment.beforeSlot, + value['recurring']['segments'][0]['beforeSlot'], + ); + final row = await target.select(target.schedules).getSingle(); + expect(row.timeZoneId, zone); + expect(row.occurrenceOffsetSeconds, 0); + expect(row.recurringOrdinal, 3); + expect(row.isStarted, isFalse); + expect(jsonEncode(value), original); + }, + ); + } + for (final boundary in [ + 'open', + 'future before', + 'future until', + 'count only', + ]) { + test( + 'unknown $boundary with no materialized row cannot become ready', + () async { + final value = input(); + value['schedules'] = []; + value['schedulePreparations'] = {}; + changeRule(value, (rule) { + rule['until'] = boundary == 'future until' + ? '2030-01-03T00:00:00' + : null; + rule['count'] = boundary == 'count only' ? 3 : null; + }); + if (boundary == 'future before') { + value['recurring']['segments'][0]['beforeSlot'] = + '2030-01-03T09:00:00'; + } + await expectLater( + validate(value), + throwsA( + isA().having( + (e) => e.kind, + 'typed time review', + BackupFailureKind.timeZoneChoiceRequired, + ), + ), + ); + }, + ); + } + test( + 'historical rule cannot hide its future unresolved time override', + () async { + final value = input(); + value['schedules'][0]['civilTime'] = '2030-01-03T09:00:00'; + value['schedules'][0]['recurringOverrides'] = 'time'; + await expectLater( + validate(value), + throwsA( + isA().having( + (e) => e.kind, + 'typed time review', + BackupFailureKind.timeZoneChoiceRequired, + ), + ), + ); + }, + ); + final invalid = )>{ + 'calendar membership': (value) => + changeRule(value, (rule) => rule['interval'] = 3), + 'ordinal upper bound': (value) => + value['schedules'][0]['recurringOrdinal'] = 4, + 'count upper bound': (value) => + changeRule(value, (rule) => rule['count'] = 2), + 'owner': (value) => + value['recurring']['definitions'][0]['ownerId'] = 'other-series', + 'duplicate combined slot': (value) => value['recurring']['exclusions'] = [ + { + 'segmentId': 'seg', + 'slotKey': value['schedules'][0]['recurringSlotKey'], + 'ordinal': 3, + }, + ], + }; + for (final entry in invalid.entries) { + test('unknown preserved rule still rejects ${entry.key}', () async { + // Count and until are mutually exclusive. Isolate the ordinal/count + // constraint rather than letting the rule shape reject this first. + final value = input(closed: entry.key == 'count upper bound'); + entry.value(value); + await expectLater( + validate(value), + throwsA( + isA().having( + (e) => e.kind, + 'hard invariant', + BackupFailureKind.dataInvariant, + ), + ), + ); + }); + } +} diff --git a/test/core/backup/backup_validated_ingestion_test.dart b/test/core/backup/backup_validated_ingestion_test.dart new file mode 100644 index 00000000..a1871328 --- /dev/null +++ b/test/core/backup/backup_validated_ingestion_test.dart @@ -0,0 +1,427 @@ +import 'dart:convert'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/core/backup/backup_recurrence_scan.dart'; +import 'backup_validation_contract_test.dart' show validBackup; + +Future validate( + Map value, { + BackupBudget? budget, + DateTime? now, + BackupOffsetLookup offsetLookup = backupOffsets, +}) async { + final store = BackupIngestionStore.memoryForTesting(budget ?? BackupBudget()); + addTearDown(store.release); + final bytes = utf8.encode(jsonEncode(value)); + final root = await BackupJsonReader( + store, + store.budget, + ).read(Stream.value(bytes)); + return BackupValidatedIngestion.validateTestStore( + store, + root, + nowUtc: now ?? DateTime.utc(2026, 9, 24), + offsetLookup: offsetLookup, + ); +} + +Map recurringBackup({int year = 2025, int ordinal = 3}) { + final input = jsonDecode(jsonEncode(validBackup())) as Map; + input['formatVersion'] = 2; + final time = DateTime.utc(year, 1, 3, 9).toIso8601String(); + final start = DateTime.utc(year, 1, 1, 9).toIso8601String(); + final schedule = input['schedules'][0] as Map; + schedule.addAll({ + 'civilTime': time, + 'timeZoneId': 'UTC', + 'occurrenceOffsetSeconds': 0, + 'recurringSegmentId': 'seg', + 'recurringSlotKey': time, + 'recurringOrdinal': ordinal, + 'preparationDefinitionId': 'def', + 'recurringOverrides': '', + }); + input['recurring'] = { + 'definitions': [ + { + 'id': 'def', + 'ownerId': 'series', + 'scope': 'recurring', + 'name': 'Prep', + 'createdAt': DateTime.utc(2024).millisecondsSinceEpoch, + }, + ], + 'steps': [ + { + 'id': 'step', + 'definitionId': 'def', + 'name': 'Step', + 'minutes': 0, + 'position': 0, + }, + ], + 'segments': [ + { + 'id': 'seg', + 'seriesId': 'series', + 'preparationId': 'def', + 'fromSlot': start, + 'beforeSlot': null, + 'createdAt': DateTime.utc(2024).millisecondsSinceEpoch, + 'preparationNotBefore': null, + 'ruleJson': jsonEncode({ + 'frequency': 'daily', + 'start': start, + 'zone': 'UTC', + 'interval': 1, + 'weekdays': [], + 'monthly': 'dayOfMonth', + 'monthDay': 1, + 'ordinal': 1, + 'monthWeekday': 1, + 'until': null, + 'count': null, + 'repeatedTime': null, + }), + 'scheduleJson': jsonEncode({ + 'id': 'series', + 'placeId': 'p1', + 'place': 'Office', + 'name': 'Meeting', + 'time': start, + 'zone': 'UTC', + 'offset': 0, + 'move': 0, + 'spare': 0, + 'note': '', + }), + }, + ], + 'exclusions': [], + }; + return input; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'candidate transaction rolls back all portable rows on a later insert failure', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + final second = jsonDecode(jsonEncode(input['schedules'][0])); + second['id'] = 'second'; + input['schedules'].add(second); + final checked = await validate(input); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await db.customStatement( + "CREATE TRIGGER fail_second BEFORE INSERT ON schedules WHEN NEW.id='second' BEGIN SELECT RAISE(ABORT,'injected late candidate failure'); END", + ); + await expectLater( + checked.materialize(db, pendingCleanup: false), + throwsA(isA()), + ); + for (final table in [ + 'users', + 'places', + 'schedules', + 'preparation_users', + ]) { + expect( + (await db + .customSelect('SELECT count(*) AS n FROM $table') + .getSingle()) + .read('n'), + 0, + ); + } + await db.customStatement('DROP TRIGGER fail_second'); + await checked.materialize(db, pendingCleanup: false); + await checked.validateReadBack(db, pendingCleanup: false); + expect(await db.select(db.schedules).get(), hasLength(2)); + }, + ); + test( + 'forward top-level order validates then materializes original records', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + final reversed = Map.fromEntries( + input.entries.toList().reversed, + ); + final checked = await validate(reversed); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: true); + await checked.validateReadBack(target, pendingCleanup: true); + expect(checked.preview.scheduleCount, 1); + expect((await target.select(target.schedules).getSingle()).id, 's1'); + final user = await target.select(target.users).getSingle(); + expect(user.dataRevision, 2); + expect(user.restoreCleanupPending, isTrue); + expect( + (await target.select(target.preparationUsers).getSingle()) + .preparationTime, + 0, + ); + }, + ); + test('same place identity with conflicting content is rejected', () async { + final input = jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'].add({ + ...input['schedules'][0], + 'id': 's2', + 'place': {'id': 'p1', 'name': 'Other'}, + }); + await expectLater(validate(input), throwsFormatException); + }); + test('duplicate schedule input counts before duplicate rejection', () async { + final input = jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'].add({...input['schedules'][0]}); + final budget = BackupBudget(); + await expectLater(validate(input, budget: budget), throwsFormatException); + expect(budget.schedules, 2); + }); + for (final civil in ['2025-11-02T01:30:00', '2025-03-09T02:30:00']) { + test('old null-offset history preserves uncertainty for $civil', () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0]['civilTime'] = civil; + input['schedules'][0]['timeZoneId'] = 'America/New_York'; + input['schedules'][0]['occurrenceOffsetSeconds'] = null; + final checked = await validate(input); + expect(checked.uncertainHistoryCount, 1); + expect(checked.timezoneChangeCount, 0); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: false); + expect( + (await target.select(target.schedules).getSingle()) + .occurrenceOffsetSeconds, + isNull, + ); + }); + } + for (final civil in ['2027-11-07T01:30:00', '2027-03-14T02:30:00']) { + test( + 'future gap or unresolved overlap blocks preview for $civil', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0]['civilTime'] = civil; + input['schedules'][0]['timeZoneId'] = 'America/New_York'; + input['schedules'][0]['occurrenceOffsetSeconds'] = null; + await expectLater( + validate(input), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.timeZoneChoiceRequired, + ), + ), + ); + }, + ); + } + test( + 'huge legal legacy duration is not silently given a new UI limit', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0]['moveTimeMinutes'] = 50000; + expect((await validate(input)).preview.scheduleCount, 1); + }, + ); + test( + 'recurring original records pass per-segment bounded validation', + () async { + final checked = await validate(recurringBackup()); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: false); + await checked.validateReadBack(target, pendingCleanup: false); + expect( + (await target.select(target.schedules).getSingle()).recurringOrdinal, + 3, + ); + }, + ); + test( + 'calendar ordinal upper bound rejects even historic UTC input', + () async { + await expectLater( + validate(recurringBackup(ordinal: 4)), + throwsFormatException, + ); + }, + ); + test( + 'schedule and exclusion cannot claim the same normalized civil slot', + () async { + final input = recurringBackup(); + input['recurring']['exclusions'].add({ + 'segmentId': 'seg', + 'slotKey': '2025-01-03T09:00:00', + 'ordinal': 3, + }); + await expectLater(validate(input), throwsFormatException); + }, + ); + test( + 'historic current-rule ordinal mismatch preserves original and reports uncertainty', + () async { + List changed(DateTime civil, String zone, BackupBudget budget) { + budget.visit(); + return civil.day == 1 ? [] : [0]; + } + + final checked = await validate(recurringBackup(), offsetLookup: changed); + expect(checked.uncertainHistoryCount, 1); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: false); + expect( + (await target.select(target.schedules).getSingle()).recurringOrdinal, + 3, + ); + }, + ); + test( + 'future current-rule ordinal mismatch blocks without rewriting', + () async { + List changed(DateTime civil, String zone, BackupBudget budget) { + budget.visit(); + return civil.day == 1 ? [] : [0]; + } + + await expectLater( + validate(recurringBackup(year: 2027), offsetLookup: changed), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.timeZoneChoiceRequired, + ), + ), + ); + }, + ); + test('legacy per-step safe duration sum cannot overflow', () async { + final input = jsonDecode(jsonEncode(validBackup())) as Map; + input['defaultPreparation'] = [ + { + 'id': 'a', + 'name': 'A', + 'minutes': 0x7fffffffffffffff ~/ 60000000, + 'nextId': 'b', + }, + {'id': 'b', 'name': 'B', 'minutes': 1, 'nextId': null}, + ]; + await expectLater(validate(input), throwsFormatException); + }); + test( + 'checked sum and year boundary reject before target database exists', + () async { + final input = + jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0]['moveTimeMinutes'] = 0x7fffffffffffffff ~/ 60000000; + input['schedules'][0]['spareTimeMinutes'] = 1; + await expectLater(validate(input), throwsFormatException); + }, + ); + test( + '1000 definition steps are effective even when schedule preparation is empty', + () async { + final input = recurringBackup(year: 2); + input['recurring']['steps'] = [ + for (var i = 0; i < 1000; i++) + { + 'id': 'step-$i', + 'definitionId': 'def', + 'name': 'Step', + 'minutes': 1440, + 'position': i, + }, + ]; + // 1000 individually legal days exceed year 0002's distance from year 0001. + await expectLater(validate(input), throwsFormatException); + final legal = recurringBackup(); + legal['recurring']['steps'] = input['recurring']['steps']; + expect((await validate(legal)).preview.scheduleCount, 1); + }, + ); + test('checked legacy lead subtraction cannot wrap below year one', () async { + final input = validBackup(); + input['schedules'][0]['civilTime'] = '0001-01-02T09:00:00Z'; + input['schedules'][0]['moveTimeMinutes'] = 0x7fffffffffffffff ~/ 60000000; + await expectLater(validate(input), throwsFormatException); + }); + test( + 'historic null instant and ordinal mismatch count once with bounded examples', + () async { + final input = recurringBackup(); + input['schedules'][0]['occurrenceOffsetSeconds'] = null; + List changed(DateTime c, String z, BackupBudget b) { + b.visit(); + return c.day == 1 ? [] : [0]; + } + + final checked = await validate(input, offsetLookup: changed); + expect(checked.preview.uncertainHistoryCount, 1); + expect(checked.preview.uncertainHistoryExamples, hasLength(1)); + expect(checked.preview.uncertainHistoryExamples.single.name, 'Meeting'); + }, + ); + test( + 'multiple original segments retain independent bounded ordinal and exclusion relations', + () async { + final first = recurringBackup(); + final second = recurringBackup(year: 2024); + final segment = second['recurring']['segments'][0]; + segment['id'] = 'seg2'; + segment['seriesId'] = 'series'; + segment['preparationId'] = 'def2'; + segment['beforeSlot'] = '2025-01-01T09:00:00Z'; + final base = jsonDecode(segment['scheduleJson']); + base['id'] = 'series'; + segment['scheduleJson'] = jsonEncode(base); + final def = second['recurring']['definitions'][0]; + def['id'] = 'def2'; + def['ownerId'] = 'series'; + final step = second['recurring']['steps'][0]; + step['id'] = 'step2'; + step['definitionId'] = 'def2'; + final schedule = second['schedules'][0]; + schedule['id'] = 's2'; + schedule['recurringSegmentId'] = 'seg2'; + schedule['preparationDefinitionId'] = 'def2'; + first['schedules'].add(schedule); + for (final kind in ['definitions', 'steps', 'segments']) { + first['recurring'][kind].addAll(second['recurring'][kind]); + } + first['recurring']['exclusions'].add({ + 'segmentId': 'seg2', + 'slotKey': '2024-01-02T09:00:00Z', + 'ordinal': 2, + }); + final checked = await validate(first); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(target.close); + await checked.materialize(target, pendingCleanup: false); + await checked.validateReadBack(target, pendingCleanup: false); + expect(checked.preview.scheduleCount, 2); + expect( + await target.select(target.recurringScheduleSegments).get(), + hasLength(2), + ); + }, + ); +} diff --git a/test/core/backup/backup_validation_contract_test.dart b/test/core/backup/backup_validation_contract_test.dart new file mode 100644 index 00000000..74fc1ff1 --- /dev/null +++ b/test/core/backup/backup_validation_contract_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_content.dart'; + +Map validBackup() => { + 'formatVersion': 1, + 'cutoff': '2026-09-24T00:00:00.000Z', + 'sourceAppVersion': '1.1.0+56', + 'sourcePlatform': 'android', + 'dataRevision': 1, + 'profile': { + 'spareTimeMinutes': 0, + 'note': '', + 'isOnboardingCompleted': true, + 'eligibleOutcomeCount': 0, + 'onTimeOutcomeCount': 0, + }, + 'preferences': { + 'alarmsEnabled': true, + 'alarmOffsetMinutes': 0, + 'detailedNotificationContent': false, + }, + 'schedules': [ + { + 'id': 's1', + 'place': {'id': 'p1', 'name': 'Office'}, + 'name': 'Meeting', + 'civilTime': '2026-09-01T09:00:00.000Z', + 'timeZoneId': 'Asia/Seoul', + 'occurrenceOffsetSeconds': 32400, + 'moveTimeMinutes': 0, + 'spareTimeMinutes': 0, + 'isChanged': false, + 'note': '', + 'latenessTime': -1, + 'doneStatus': 'notEnded', + 'preparationTemplateDeleted': false, + 'scoreContributionRecorded': false, + }, + ], + 'defaultPreparation': [ + {'id': 'a', 'name': 'Prepare', 'minutes': 0, 'nextId': null}, + ], + 'schedulePreparations': {}, + 'templates': [], +}; + +void main() { + test( + 'valid legacy zero duration and null optional fields remain readable', + () { + expect(BackupContent.fromJson(validBackup()).schedules, hasLength(1)); + }, + ); + test('cyclic source preparation is rejected before ordering can mask it', () { + final input = validBackup(); + input['defaultPreparation'] = [ + {'id': 'a', 'name': 'A', 'minutes': 1, 'nextId': 'b'}, + {'id': 'b', 'name': 'B', 'minutes': 1, 'nextId': 'a'}, + ]; + expect(() => BackupContent.fromJson(input), throwsFormatException); + }); + test('unknown named zone cannot silently become UTC', () { + final input = validBackup(); + input['schedules'][0]['timeZoneId'] = 'Not/AZone'; + expect(() => BackupContent.fromJson(input), throwsFormatException); + }); + test( + 'civil date overflow is rejected instead of changing the appointment', + () { + final input = validBackup(); + input['schedules'][0]['civilTime'] = '2026-02-30T09:00:00.000Z'; + expect(() => BackupContent.fromJson(input), throwsFormatException); + }, + ); + test('negative optional spare time is rejected before date arithmetic', () { + final input = validBackup(); + input['schedules'][0]['spareTimeMinutes'] = -1; + expect(() => BackupContent.fromJson(input), throwsFormatException); + }); + test('overlong identifier is rejected before admission', () { + final input = validBackup(); + input['schedules'][0]['id'] = 'x' * 513; + expect(() => BackupContent.fromJson(input), throwsFormatException); + }); + test( + 'revision must leave exact representation space for restore increment', + () { + final input = validBackup(); + input['dataRevision'] = 9007199254740991; + expect(() => BackupContent.fromJson(input), throwsFormatException); + }, + ); +} diff --git a/test/core/backup/detailed_notification_backup_generation_acceptance_test.dart b/test/core/backup/detailed_notification_backup_generation_acceptance_test.dart new file mode 100644 index 00000000..64b77d04 --- /dev/null +++ b/test/core/backup/detailed_notification_backup_generation_acceptance_test.dart @@ -0,0 +1,294 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_file_export_port.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/presentation/my_page/cubit/detailed_notification_settings_cubit.dart'; + +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1.0', buildNumber: '1'); +} + +class _Destination implements BackupFileExportPort { + final opened = Completer(); + final finish = Completer(); + Uint8List? bytes; + @override + Future exportStream({ + required Stream> encrypted, + required String suggestedName, + BackupProcessingLease? lease, + }) async { + final output = BytesBuilder(copy: false); + await for (final chunk in encrypted) { + output.add(chunk); + } + bytes = output.takeBytes(); + opened.complete(); + return finish.future; + } +} + +// Backup boundaries use a result-only delivery port. Real reconciliation is +// covered in the separate delivery acceptance file, not claimed here. +class _NoDelivery extends Fake implements ReconcileAlarmsUseCase { + @override + Future call() async => AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.none, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: DateTime.utc(2030), + scheduleWindowEnd: DateTime.utc(2030, 1, 2), + alarmCoverageStart: DateTime.utc(2030), + alarmCoverageEnd: DateTime.utc(2030, 1, 2), + ); +} + +class _QueuedWrite implements DetailedNotificationPreferencePort { + _QueuedWrite(this.actual); + final DetailedNotificationPreferenceService actual; + final entered = Completer(), + release = Completer(), + drained = Completer(); + @override + Future read({ + required int expectedGeneration, + }) => actual.read(expectedGeneration: expectedGeneration); + @override + Future write( + bool enabled, { + required int expectedGeneration, + }) async { + entered.complete(); + await release.future; + try { + return await actual.write( + enabled, + expectedGeneration: expectedGeneration, + ); + } finally { + drained.complete(); + } + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const password = 'detailed preference backup password'; + late AppDatabase db; + late LocalDataOperationGate gate; + late AlarmOperationCoordinator owner; + late DetailedNotificationPreferenceService preferences; + late BackupService backup; + late _Destination destination; + late RestoreRuntimeIdentity identity; + late BackupCrypto crypto; + bool failCleanup = false; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + db = AppDatabase.forTesting(NativeDatabase.memory()); + gate = LocalDataOperationGate(); + owner = AlarmOperationCoordinator(gate); + preferences = DetailedNotificationPreferenceService(db, gate: gate); + destination = _Destination(); + identity = RestoreRuntimeIdentity(); + crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + failCleanup = false; + backup = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: crypto, + operationGate: gate, + exportPort: destination, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: identity, + cleanupPlatform: () async { + if (failCleanup) throw StateError('unconfirmed'); + }, + ); + await db.customStatement( + "INSERT INTO users(id,spare_time,note,data_revision,detailed_notification_content," + "store_incarnation) VALUES('local-profile',0,'preserved',9,1,'old-store')", + ); + }); + tearDown(() async { + if (!destination.finish.isCompleted) { + destination.finish.complete(BackupFileExportReceipt.cancelled); + } + owner.dispose(); + gate.dispose(); + await db.close(); + }); + + test( + 'encrypted export freezes ON at cutoff while OFF commits during destination selection', + () async { + final exporting = backup.exportToUserSelectedFile(password); + try { + await Future.any([ + destination.opened.future, + exporting.then((_) { + fail('export ended without reaching destination'); + }), + ]); + final decoded = + jsonDecode( + utf8.decode( + await crypto.decrypt( + container: destination.bytes!, + password: password, + ), + ), + ) + as Map; + expect(decoded['preferences']['detailedNotificationContent'], isTrue); + expect(decoded['dataRevision'], 9); + await preferences.write(false, expectedGeneration: 0); + destination.finish.complete(BackupFileExportReceipt.saved); + expect(await exporting, BackupExportResult.saved); + final row = await db.select(db.users).getSingle(); + expect(row.detailedNotificationContent, isFalse); + expect(row.dataRevision, 10); + expect(row.lastExportedRevision, 9); + final again = + jsonDecode( + utf8.decode( + await crypto.decrypt( + container: destination.bytes!, + password: password, + ), + ), + ) + as Map; + expect(again, decoded); + final next = await backup.createEncryptedBackup(password); + final nextDecoded = + jsonDecode( + utf8.decode( + await crypto.decrypt(container: next, password: password), + ), + ) + as Map; + expect( + nextDecoded['preferences']['detailedNotificationContent'], + isFalse, + ); + expect(nextDecoded['dataRevision'], 10); + } finally { + if (!destination.finish.isCompleted) { + destination.finish.complete(BackupFileExportReceipt.cancelled); + } + await exporting; + } + }, + ); + + test( + 'restore renews store and rejects accepted old OFF when queued write finally runs', + () async { + final bytes = await backup.createEncryptedBackup(password); + final queued = _QueuedWrite(preferences); + final controller = DetailedNotificationSettingsCubit.test( + queued, + _NoDelivery(), + operations: owner, + ); + final candidate = await backup.previewEncryptedBackup(bytes, password); + try { + await controller.refresh(); + controller.request(false); + await queued.entered.future.timeout(const Duration(seconds: 10)); + expect(controller.state.requestedEnabled, isFalse); + await backup.applyRestore(candidate); + final restored = await db.select(db.users).getSingle(); + expect(gate.generation, 1); + expect(restored.storeIncarnation, isNot('old-store')); + expect(restored.detailedNotificationContent, isTrue); + queued.release.complete(); + await queued.drained.future; + // Observe the controller's post-error state without a wall-clock delay. + await Future.delayed(Duration.zero); + expect(controller.state.requestedEnabled, isNull); + expect(controller.state.load, DetailedPreferenceLoad.unavailable); + expect( + (await db.select(db.users).getSingle()).dataRevision, + restored.dataRevision, + ); + expect( + (await db.select(db.users).getSingle()).detailedNotificationContent, + isTrue, + ); + await controller.refresh(); + expect(controller.state.confirmedEnabled, isTrue); + expect(controller.state.generation, 1); + } finally { + if (!queued.release.isCompleted) queued.release.complete(); + await controller.close(); + await candidate.dispose(); + } + }, + ); + + test( + 'committed restore with pending cleanup has no readable setting authority until cleanup', + () async { + await preferences.write(false, expectedGeneration: 0); + final bytes = await backup.createEncryptedBackup(password); + await preferences.write(true, expectedGeneration: 0); + final candidate = await backup.previewEncryptedBackup(bytes, password); + failCleanup = true; + try { + await backup.applyRestore(candidate); + final restored = await db.select(db.users).getSingle(); + expect(restored.detailedNotificationContent, isFalse); + expect(restored.restoreCleanupPending, isTrue); + expect(gate.isRecoveryPending, isTrue); + await expectLater( + preferences.read(expectedGeneration: gate.generation), + throwsA(isA()), + ); + failCleanup = false; + await backup.finishRestoreCleanup(); + expect( + (await preferences.read( + expectedGeneration: gate.generation, + )).detailedEnabled, + isFalse, + ); + final cleaned = await db.select(db.users).getSingle(); + expect(cleaned.storeIncarnation, restored.storeIncarnation); + expect(cleaned.dataRevision, restored.dataRevision); + expect(cleaned.restoreCleanupPending, isFalse); + } finally { + await candidate.dispose(); + } + }, + ); +} diff --git a/test/core/backup/restore_commit_response_acceptance_test.dart b/test/core/backup/restore_commit_response_acceptance_test.dart new file mode 100644 index 00000000..41fa7031 --- /dev/null +++ b/test/core/backup/restore_commit_response_acceptance_test.dart @@ -0,0 +1,700 @@ +// T03 F09 contract — actual outer COMMIT response-loss acceptance. +// Actual host SQLite + actual BackupService/sodium; +// memory staging, SharedPreferences and OS cleanup are explicit test ports. +// It does not prove SQLCipher, mobile secure storage, process death or power loss. +import 'dart:async'; +import 'dart:io'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/data/adapters/local_data_workflow_adapters.dart'; +import 'package:on_time_front/data/repositories/user_repository_impl.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/ports/local_data_ports.dart'; +import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; + +enum _Cut { beforeCommit, afterCommit } + +// Marks only the real getUser call; repository watch queries are outside it. +final _identityReadZone = Object(); + +// Drift 2.31.0 exposes this public interceptor hook. The restore pending-marker +// UPDATE occurs after nested delete/copy transactions, so arming at this write +// avoids confusing RELEASE SAVEPOINT with the outer durable COMMIT. +final class _RestoreCommitResponse extends QueryInterceptor { + _Cut? cut; + final _outerTransactions = Set.identity(); + TransactionExecutor? _restoreTransaction; + + @override + TransactionExecutor beginTransaction(QueryExecutor parent) { + final transaction = parent.beginTransaction(); + if (parent is! TransactionExecutor) _outerTransactions.add(transaction); + return transaction; + } + + @override + Future rollbackTransaction(TransactionExecutor inner) async { + try { + await inner.rollback(); + } finally { + _outerTransactions.remove(inner); + } + } + + int markerHits = 0; + int injected = 0; + int completedRestoreSends = 0; + bool failReadbackAfterLoss = false; + bool blockAppReads = false; + int failedAppReads = 0; + int identityReadSelects = 0; + final identityFetched = Completer(); + final releaseIdentity = Completer(); + Map? fetchedIdentity; + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + if (blockAppReads) { + failedAppReads++; + throw StateError('Synthetic postcommit app readback unavailable'); + } + final identityCall = Zone.current[_identityReadZone] == true; + final selectedRead = identityCall ? ++identityReadSelects : 0; + final rows = await executor.runSelect(statement, args); + if (selectedRead == 2) { + // getUserById is read 1; RestoreRuntimeIdentity.load is read 2. + // Only the already fetched result is delayed. No transaction/DB lock is + // held, and the actual active DB remains available for restore. + expect(executor, isNot(isA())); + expect(statement, contains('users')); + expect(args, contains('local-profile')); + fetchedIdentity = Map.from(rows.single); + identityFetched.complete(); + await releaseIdentity.future; + } + return rows; + } + + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final changed = await executor.runUpdate(statement, args); + if (cut != null && statement.contains('restore_cleanup_pending')) { + // Public executor identity must identify the outer transaction, not a + // nested savepoint. Fail the fixture before classifying any product bug. + expect(executor, isA()); + expect(_outerTransactions.contains(executor), true); + markerHits++; + _restoreTransaction = executor as TransactionExecutor; + } + return changed; + } + + @override + Future commitTransaction(TransactionExecutor inner) async { + if (!identical(_restoreTransaction, inner)) { + try { + await inner.send(); + } finally { + _outerTransactions.remove(inner); + } + return; + } + expect(_outerTransactions.contains(inner), true); + _restoreTransaction = null; + final current = cut!; + cut = null; // One shot; cleanup commits and a valid retry are unaffected. + injected++; + if (current == _Cut.beforeCommit) { + throw StateError('Synthetic precommit transaction fault'); + } + try { + await inner.send(); // Actual successful outer SQLite COMMIT comes first. + } finally { + _outerTransactions.remove(inner); + } + completedRestoreSends++; + blockAppReads = failReadbackAfterLoss; + throw StateError('Synthetic response loss after actual restore commit'); + } +} + +final class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1', buildNumber: '1'); +} + +final class _Cleanup extends NoopAlarmCleanup { + int calls = 0; + @override + Future forDataReplacement() async { + calls++; + } +} + +final class _Delivery implements RestoreDeliveryPort { + _Delivery(this.service); + final BackupService service; + int calls = 0; + @override + Future reconcile() async { + calls++; + // Same production cleanup entry as LocalRestoreDeliveryAdapter, but no OS + // reconciliation success is inferred: the controlled platform port decides. + await service.finishRestoreCleanup(); + return true; + } +} + +final class _Fixture { + _Fixture( + this.directory, { + LocalDataOperationGate? gate, + RestoreRuntimeIdentity? identity, + }) : gate = gate ?? LocalDataOperationGate(), + identity = identity ?? RestoreRuntimeIdentity() { + file = File('${directory.path}/active.sqlite'); + db = AppDatabase.forTesting( + NativeDatabase(file).interceptWith(interceptor), + ); + service = BackupService( + db, + _Metadata(), + cleanup, + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: this.gate, + runtimeIdentity: this.identity, + cleanupPlatform: () async { + platformCalls++; + if (platformFails) throw StateError('Synthetic cleanup unavailable'); + }, + ingestionFactory: memoryBackupIngestion, + stagingFactory: memoryRestoreStaging, + processingOwner: testBackupProcessingOwner(), + ); + delivery = _Delivery(service); + workflow = BackupWorkflow(LocalBackupAdapter(service), delivery); + } + + final Directory directory; + final interceptor = _RestoreCommitResponse(); + final LocalDataOperationGate gate; + final RestoreRuntimeIdentity identity; + final cleanup = _Cleanup(); + late final File file; + late final AppDatabase db; + late final BackupService service; + late final _Delivery delivery; + late final BackupWorkflow workflow; + BackupRestoreCandidate? input; + int backupRevision = -1; + bool platformFails = true; + int platformCalls = 0; + bool _closed = false; + + Future open() async { + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'F09 restored synthetic note', + ), + ); + backupRevision = (await db.select(db.users).getSingle()).dataRevision; + const password = 'F09 synthetic backup password'; + final bytes = await service.createEncryptedBackup(password); + // Two actual writes distinguish the current revision from backup+1. + await db.userDao.completeOnboarding( + userId: 'local-profile', + spareTime: const Duration(minutes: 7), + note: 'F09 active synthetic note', + preparationChanged: false, + ); + await db.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 9), + ); + await identity.load(db); + input = await service.previewEncryptedBackup(bytes, password); + } + + // Independent connection, no AppDatabase mapper, service cache or transaction + // handle. It observes only committed rows and closes before every next action. + Map row() { + final reader = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + try { + expect(reader.select('PRAGMA foreign_key_check'), isEmpty); + return Map.from( + reader.select("SELECT * FROM users WHERE id='local-profile'").single, + ); + } finally { + reader.dispose(); + } + } + + Future closeDb() async { + if (_closed) return; + _closed = true; + await db.close(); + } + + Future dispose() async { + try { + await input?.dispose(); + } finally { + try { + await closeDb(); + } finally { + if (!identical(gate, LocalDataOperationGate.shared)) { + gate.dispose(); + } + await directory.delete(recursive: true); + } + } + } +} + +Map _withoutPending(Map row) => + Map.from(row)..remove('restore_cleanup_pending'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _Fixture rig; + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'early_start_session_f09': 'old synthetic runtime', + 'unrelated': 'keep', + }); + rig = _Fixture(await Directory.systemTemp.createTemp('t03-f09-')); + addTearDown(rig.dispose); + await rig.open(); + }); + + test( + 'F09 precommit failure preserves the original file and same candidate retries once', + () async { + final before = rig.row(); + rig.interceptor.cut = _Cut.beforeCommit; + final rejected = await rig.workflow.restore(rig.input!); + expect(rig.interceptor.markerHits, 1); + expect(rig.interceptor.injected, 1); + expect(rig.interceptor.completedRestoreSends, 0); + expect(rejected.disposition, BackupCommitDisposition.notCommitted); + expect(rig.row(), before); + expect(rig.platformCalls, 0); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_f09', + ), + 'old synthetic runtime', + ); + + rig.platformFails = false; + final retry = await rig.workflow.restore(rig.input!); + expect(retry.disposition, BackupCommitDisposition.committed); + expect(retry.followUpPending, false); + final committed = rig.row(); + expect(committed['note'], 'F09 restored synthetic note'); + expect(committed['data_revision'], rig.backupRevision + 1); + expect( + committed['store_incarnation'], + isNot(before['store_incarnation']), + ); + expect(committed['restore_cleanup_pending'], 0); + expect(rig.gate.isAvailable, true); + }, + ); + + test( + 'F09 actual commit response loss reports committed pending and only cleanup is retried', + () async { + final before = rig.row(); + final initial = Completer(); + final restoredNote = Completer(); + final subscription = rig.db.select(rig.db.users).watch().listen((rows) { + if (!initial.isCompleted) { + initial.complete(); + } + if (rows.single.note == 'F09 restored synthetic note' && + !restoredNote.isCompleted) { + restoredNote.complete(rows.single.note); + } + }); + addTearDown(subscription.cancel); + await initial.future.timeout(const Duration(seconds: 5)); + rig.interceptor.cut = _Cut.afterCommit; + final receipt = await rig.workflow.restore(rig.input!); + // First establish the physical fact independently of the workflow receipt. + final committed = rig.row(); + expect(rig.interceptor.markerHits, 1); + expect(rig.interceptor.injected, 1); + expect(rig.interceptor.completedRestoreSends, 1); + expect(committed['note'], 'F09 restored synthetic note'); + expect(committed['data_revision'], rig.backupRevision + 1); + expect(committed['data_revision'], isNot(before['data_revision'])); + expect( + committed['store_incarnation'], + isNot(before['store_incarnation']), + ); + expect(committed['restore_cleanup_pending'], 1); + expect(committed['reject_legacy_delivery'], 1); + + // Expected to expose a current product gap until authoritative postcommit + // readback is implemented. Never relax this to notCommitted because send() + // threw: the independent reader above already proved a committed replacement. + expect(receipt.disposition, BackupCommitDisposition.committed); + expect(receipt.followUpPending, true); + expect( + await restoredNote.future.timeout(const Duration(seconds: 5)), + committed['note'], + reason: 'Existing consumers must observe the confirmed replacement', + ); + expect(receipt.generation, 1); + expect(rig.gate.generation, 1); + expect(rig.gate.isRecoveryPending, true); + expect(rig.gate.isAvailable, false); + expect(rig.identity.accepts(before['store_incarnation']), false); + expect(rig.identity.accepts(committed['store_incarnation']), false); + expect(rig.platformCalls, greaterThan(0)); // Explicit cleanup failure. + final replacementCalls = rig.cleanup.calls; + + // Repeating the same UI intent returns its held commit receipt, no apply. + expect( + (await rig.workflow.restore(rig.input!)).disposition, + BackupCommitDisposition.committed, + ); + expect(rig.cleanup.calls, replacementCalls); + expect(rig.row(), committed); + + rig.platformFails = false; + final recovered = await rig.workflow.retryFollowUp(receipt); + final after = rig.row(); + expect(recovered.disposition, BackupCommitDisposition.committed); + expect(recovered.followUpPending, false); + expect(_withoutPending(after), _withoutPending(committed)); + expect(after['restore_cleanup_pending'], 0); + expect(rig.cleanup.calls, replacementCalls); + expect(rig.gate.generation, 1); + expect(rig.gate.isAvailable, true); + expect(rig.identity.accepts(before['store_incarnation']), false); + expect(rig.identity.accepts(after['store_incarnation']), true); + expect((await SharedPreferences.getInstance()).getKeys(), {'unrelated'}); + + await expectLater( + rig.service.applyRestoreWithReceipt(rig.input!), + throwsA( + isA().having( + (error) => error.failure, + 'consumed candidate', + DataOperationFailure.stalePreview, + ), + ), + ); + expect(rig.row(), after); + expect(rig.gate.generation, 1); + }, + ); + + test( + 'F09 cold file reopen recovers the committed marker without a second import', + () async { + final before = rig.row(); + rig.interceptor.cut = _Cut.afterCommit; + await rig.workflow.restore(rig.input!); + final committed = rig.row(); + expect(rig.interceptor.completedRestoreSends, 1); + expect( + committed['store_incarnation'], + isNot(before['store_incarnation']), + ); + expect(committed['restore_cleanup_pending'], 1); + expect(committed['data_revision'], rig.backupRevision + 1); + await rig.closeDb(); + + final cold = AppDatabase.forTesting(NativeDatabase(rig.file)); + final gate = LocalDataOperationGate(); + final identity = RestoreRuntimeIdentity(); + var calls = 0; + try { + await expectLater( + identity.prepareStartup( + cold, + gate, + cleanupPlatform: () async { + calls++; + throw StateError('Synthetic cleanup still unavailable'); + }, + ), + throwsA(isA()), + ); + expect(rig.row(), committed); + expect(gate.isRecoveryPending, true); + expect(identity.accepts(before['store_incarnation']), false); + expect(identity.accepts(committed['store_incarnation']), false); + await identity.prepareStartup( + cold, + gate, + cleanupPlatform: () async { + calls++; + }, + ); + final after = rig.row(); + expect(_withoutPending(after), _withoutPending(committed)); + expect(after['restore_cleanup_pending'], 0); + expect(calls, 2); + expect( + gate.generation, + 0, + ); // Fresh owner did not perform another restore. + expect(gate.isAvailable, true); + expect(identity.accepts(after['store_incarnation']), true); + expect(rig.cleanup.calls, 1); + } finally { + await cold.close(); + gate.dispose(); + } + }, + ); + + test( + 'F09 unavailable app readback stays undetermined until fresh startup authority', + () async { + final before = rig.row(); + rig.interceptor + ..failReadbackAfterLoss = true + ..cut = _Cut.afterCommit; + try { + final receipt = await rig.workflow.restore(rig.input!); + // This reader deliberately bypasses the failed app connection. It proves + // the fixture's real COMMIT, but gives no authority to the running app. + final committed = rig.row(); + expect(rig.interceptor.completedRestoreSends, 1); + expect( + committed['store_incarnation'], + isNot(before['store_incarnation']), + ); + expect(committed['note'], 'F09 restored synthetic note'); + expect(committed['restore_cleanup_pending'], 1); + expect(committed['data_revision'], rig.backupRevision + 1); + expect( + rig.interceptor.failedAppReads, + greaterThan(0), + reason: 'The app must actually attempt authoritative readback', + ); + expect(receipt.disposition, BackupCommitDisposition.undetermined); + expect(rig.gate.isRecoveryPending, true); + expect(rig.gate.isAvailable, false); + expect(rig.gate.captureWrite, throwsA(isA())); + expect(rig.identity.accepts(before['store_incarnation']), false); + expect(rig.identity.accepts(committed['store_incarnation']), false); + expect(rig.delivery.calls, 0); + expect(rig.platformCalls, 0); + final generation = rig.gate.generation; + final replacements = rig.cleanup.calls; + final sends = rig.interceptor.completedRestoreSends; + + final repeated = await rig.workflow.restore(rig.input!); + expect(repeated.disposition, BackupCommitDisposition.undetermined); + final noPromotion = await rig.workflow.retryFollowUp(receipt); + expect(noPromotion.disposition, BackupCommitDisposition.undetermined); + expect(rig.cleanup.calls, replacements); + expect(rig.interceptor.completedRestoreSends, sends); + expect(rig.gate.generation, generation); + expect(rig.delivery.calls, 0); + expect(rig.platformCalls, 0); + expect(rig.row(), committed); + + // Losing the response did not remove the durable recovery marker. A new + // startup owner can resolve it after opening the file independently. + rig.interceptor.blockAppReads = false; + await rig.closeDb(); + final cold = AppDatabase.forTesting(NativeDatabase(rig.file)); + final gate = LocalDataOperationGate(); + final identity = RestoreRuntimeIdentity(); + var platform = 0; + try { + await identity.prepareStartup( + cold, + gate, + cleanupPlatform: () async { + platform++; + }, + ); + final recovered = rig.row(); + expect(_withoutPending(recovered), _withoutPending(committed)); + expect(recovered['restore_cleanup_pending'], 0); + expect(gate.isAvailable, true); + expect(gate.generation, 0); + expect(identity.accepts(before['store_incarnation']), false); + expect(identity.accepts(recovered['store_incarnation']), true); + expect(platform, 1); + expect(rig.cleanup.calls, replacements); + } finally { + await cold.close(); + gate.dispose(); + } + } finally { + // Assertions may fail before reopen. Never let injected SELECT failure + // obscure teardown or keep a candidate/database owner hanging. + rig.interceptor.blockAppReads = false; + rig.interceptor.failReadbackAfterLoss = false; + } + }, + ); + + test( + 'F09 stale getUser identity load cannot release an unknown restore hold', + () async { + // The four existing cases retain their isolated fixtures. This case uses + // the actual shared objects required by UserRepositoryImpl.getUser. + final gate = LocalDataOperationGate.shared; + final identity = RestoreRuntimeIdentity.shared; + expect(gate.isAvailable, true); + final previousIncarnation = identity.storeIncarnation; + final previousRejectLegacy = identity.rejectLegacy; + final previousPending = identity.pending; + final previousRecoveryPending = gate.isRecoveryPending; + final race = _Fixture( + await Directory.systemTemp.createTemp('t03-f09-identity-race-'), + gate: gate, + identity: identity, + ); + UserRepositoryImpl? repository; + Future? oldGetUser; + try { + await race.open(); + final before = race.row(); + final generation = gate.generation; + expect(identity.accepts(before['store_incarnation']), true); + repository = UserRepositoryImpl(race.db); + // Finish the real repository's initial watch before marking one call. + await repository.userStream + .firstWhere( + (user) => user.valueOrNull?.note == 'F09 active synthetic note', + ) + .timeout(const Duration(seconds: 5)); + final userRepository = repository; + oldGetUser = runZoned( + () => userRepository.getUser(), + zoneValues: {_identityReadZone: true}, + ).then((value) => value, onError: (Object error) => error); + await race.interceptor.identityFetched.future.timeout( + const Duration(seconds: 5), + ); + expect(race.interceptor.identityReadSelects, 2); + expect( + race.interceptor.fetchedIdentity!['store_incarnation'], + before['store_incarnation'], + ); + expect(race.interceptor.fetchedIdentity!['restore_cleanup_pending'], 0); + + race.interceptor + ..failReadbackAfterLoss = true + ..cut = _Cut.afterCommit; + final receipt = await race.workflow.restore(race.input!); + // Raw independent SQLite observation proves this is a committed + // replacement, even though this app connection cannot classify it. + final committed = race.row(); + expect(race.interceptor.completedRestoreSends, 1); + expect(race.interceptor.failedAppReads, greaterThan(0)); + expect(committed['note'], 'F09 restored synthetic note'); + expect(committed['data_revision'], race.backupRevision + 1); + expect( + committed['store_incarnation'], + isNot(before['store_incarnation']), + ); + expect(committed['restore_cleanup_pending'], 1); + expect(receipt.disposition, BackupCommitDisposition.undetermined); + expect(gate.generation, generation + 1); + expect(gate.isRecoveryPending, true); + expect(identity.pending, true); + expect(identity.accepts(before['store_incarnation']), false); + expect(identity.accepts(committed['store_incarnation']), false); + + // Return the pre-restore row while postcommit app reads remain blocked. + // getUser's final gate check must reject its caller, but that rejection + // alone must not conceal a stale publication into the shared identity. + race.interceptor.releaseIdentity.complete(); + final oldResult = await oldGetUser.timeout(const Duration(seconds: 5)); + expect(oldResult, isA()); + expect(gate.isRecoveryPending, true); + expect(gate.isAvailable, false); + expect(gate.captureWrite, throwsA(isA())); + expect( + identity.accepts(before['store_incarnation']), + false, + reason: 'A rejected stale caller cannot reopen old runtime authority', + ); + expect(identity.accepts(committed['store_incarnation']), false); + expect(identity.pending, true); + expect(race.row(), committed); + expect(race.delivery.calls, 0); + expect(race.platformCalls, 0); + expect( + (await race.workflow.restore(race.input!)).disposition, + BackupCommitDisposition.undetermined, + ); + expect( + (await race.workflow.retryFollowUp(receipt)).disposition, + BackupCommitDisposition.undetermined, + ); + expect(race.cleanup.calls, 1); + expect(race.interceptor.completedRestoreSends, 1); + expect(race.row(), committed); + } finally { + race.interceptor + ..blockAppReads = false + ..failReadbackAfterLoss = false; + if (!race.interceptor.releaseIdentity.isCompleted) { + race.interceptor.releaseIdentity.complete(); + } + // Remove listeners before restoring shared test state. Never dispose + // the shared gate or rewind its generation for the next test. + try { + if (oldGetUser != null) { + await oldGetUser.timeout(const Duration(seconds: 5)); + } + } finally { + try { + await repository?.dispose(); + } finally { + try { + await race.dispose(); + } finally { + identity + ..storeIncarnation = previousIncarnation + ..rejectLegacy = previousRejectLegacy + ..pending = previousPending; + gate.setRecoveryPending(previousRecoveryPending); + } + } + } + } + }, + ); +} diff --git a/test/core/backup/restore_replacement_protocol_test.dart b/test/core/backup/restore_replacement_protocol_test.dart new file mode 100644 index 00000000..57c90b0a --- /dev/null +++ b/test/core/backup/restore_replacement_protocol_test.dart @@ -0,0 +1,591 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:io'; +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const password = 'portable backup password'; + late AppDatabase db; + late LocalDataOperationGate gate; + late RestoreRuntimeIdentity identity; + late BackupService service; + late Directory directory; + Future Function() platform = () async {}; + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'early_start_session_same': 'old', + 'preparation_with_time_orphan': 'old', + 'unrelated': 'keep', + }); + directory = await Directory.systemTemp.createTemp( + 'ontime-restore-protocol-', + ); + db = AppDatabase.forTesting( + NativeDatabase(File('${directory.path}/live.sqlite')), + ); + gate = LocalDataOperationGate(); + identity = RestoreRuntimeIdentity(); + platform = () async {}; + service = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: gate, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: identity, + cleanupPlatform: () => platform(), + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'original', + ), + ); + await db.scheduleDao.createSchedule( + ScheduleEntity( + id: 'same', + place: const PlaceEntity(id: 'place', placeName: 'Private'), + scheduleName: 'Private', + scheduleTime: DateTime.utc(2030, 1, 1, 10), + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: true, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + startedAt: DateTime.utc(2029, 12, 31), + preparationFrozen: true, + ).toScheduleWithPlaceRow(), + ); + await db.preparationScheduleDao.createPreparationSchedule( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Frozen', + preparationTime: Duration(minutes: 5), + ), + ], + ), + 'same', + ); + }); + tearDown(() async { + await db.close(); + await directory.delete(recursive: true); + gate.dispose(); + }); + Future preview() async => + service.previewEncryptedBackup( + await service.createEncryptedBackup(password), + password, + ); + + test( + 'same-ID restore rotates identity, removes orphan runtime and keeps frozen durable data', + () async { + final before = await db.select(db.users).getSingle(); + final candidate = await preview(); + await service.applyRestore(candidate); + final user = await db.select(db.users).getSingle(); + expect(user.storeIncarnation, isNot(before.storeIncarnation)); + expect(user.rejectLegacyDelivery, isTrue); + expect(user.restoreCleanupPending, isFalse); + expect(identity.accepts(null), isFalse); + expect(identity.accepts(before.storeIncarnation), isFalse); + expect(identity.accepts(user.storeIncarnation), isTrue); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getKeys(), {'unrelated'}); + final row = (await db.scheduleDao.getScheduleById('same')).schedule; + expect(row.startedAt, DateTime.utc(2029, 12, 31).toLocal()); + expect(row.preparationFrozen, isTrue); + expect(row.isStarted, isFalse); + expect(row.requiresStartConfirmation, isTrue); + expect( + (await db.preparationScheduleDao.getPreparationSchedulesByScheduleId( + 'same', + )).preparationStepList.single.preparationName, + 'Frozen', + ); + expect(gate.isAvailable, isTrue); + }, + ); + + test( + 'insert failure rolls back original rows and preserves original runtime', + () async { + final candidate = await preview(); + final before = await db.select(db.users).getSingle(); + await db.customStatement( + "CREATE TRIGGER fail_restore BEFORE INSERT ON schedules BEGIN SELECT RAISE(ABORT,'injected'); END", + ); + await expectLater( + service.applyRestore(candidate), + throwsA(isA()), + ); + final after = await db.select(db.users).getSingle(); + expect(after, before); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_same', + ), + 'old', + ); + expect( + (await db.scheduleDao.getScheduleById('same')).schedule.isStarted, + isTrue, + ); + await candidate.dispose(); + }, + ); + + test( + 'commit followed by cleanup failure survives file DB reopen and retries cleanup only', + () async { + platform = () async => throw StateError('platform unconfirmed'); + final candidate = await preview(); + await service.applyRestore(candidate); + final committed = await db.select(db.users).getSingle(); + expect(committed.restoreCleanupPending, isTrue); + expect(gate.isRecoveryPending, isTrue); + expect(identity.accepts(committed.storeIncarnation), isFalse); + await candidate.dispose(); + await db.close(); + db = AppDatabase.forTesting( + NativeDatabase(File('${directory.path}/live.sqlite')), + ); + final cold = RestoreRuntimeIdentity(); + final coldGate = LocalDataOperationGate(); + addTearDown(coldGate.dispose); + var nativeCalls = 0; + await expectLater( + cold.cleanup( + db, + coldGate, + cleanupPlatform: () async { + nativeCalls++; + throw StateError('still unknown'); + }, + ), + throwsStateError, + ); + expect( + (await db.select(db.users).getSingle()).restoreCleanupPending, + isTrue, + ); + await cold.cleanup( + db, + coldGate, + cleanupPlatform: () async { + nativeCalls++; + }, + ); + final after = await db.select(db.users).getSingle(); + expect(after.storeIncarnation, committed.storeIncarnation); + expect(after.dataRevision, committed.dataRevision); + expect(after.restoreCleanupPending, isFalse); + expect(nativeCalls, 2); + expect(cold.accepts(null), isFalse); + expect( + (await db.scheduleDao.getScheduleById( + 'same', + )).schedule.requiresStartConfirmation, + isTrue, + ); + }, + ); + + test( + 'restored confirmation survives legacy whole-row edit; only explicit start clears it', + () async { + await service.applyRestore(await preview()); + final original = (await db.scheduleDao.getScheduleById('same')).schedule; + await db.scheduleDao.updateSchedule( + original.copyWith( + requiresStartConfirmation: false, + scheduleName: 'edited', + ), + ); + expect( + (await db.scheduleDao.getScheduleById( + 'same', + )).schedule.requiresStartConfirmation, + isTrue, + ); + final repository = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: _Timed(), + ); + addTearDown(repository.dispose); + await expectLater( + repository.startSchedule('same'), + throwsA(isA()), + ); + final revision = (await db.select(db.users).getSingle()).dataRevision; + final first = await repository.startSchedule( + 'same', + startedAt: DateTime.utc(2030, 1, 1, 9, 58), + ); + expect(first, original.startedAt!.toUtc()); + expect( + (await db.scheduleDao.getScheduleById( + 'same', + )).schedule.requiresStartConfirmation, + isFalse, + ); + expect( + (await db.select(db.users).getSingle()).dataRevision, + revision + 1, + ); + }, + ); + + test( + 'staging materialization failure cannot mutate live rows or runtime', + () async { + var released = false; + final failing = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: gate, + runtimeIdentity: identity, + cleanupPlatform: () => platform(), + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: () async { + final stage = AppDatabase.forTesting(NativeDatabase.memory()); + await stage.customSelect('SELECT 1').get(); + await stage.customStatement( + "CREATE TRIGGER fail_stage BEFORE INSERT ON schedules BEGIN SELECT RAISE(ABORT,'injected'); END", + ); + return RestoreStaging(stage, () async { + await stage.close(); + released = true; + }); + }, + ); + final before = await db.select(db.users).getSingle(); + await expectLater( + failing.previewEncryptedBackup( + await service.createEncryptedBackup(password), + password, + ), + throwsA(anything), + ); + expect(released, isTrue); + expect(await db.select(db.users).getSingle(), before); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_same', + ), + 'old', + ); + }, + ); + + test( + 'write crossing replacement generation rolls back while export allows ordinary edits', + () async { + final started = Completer(); + final release = Completer(); + final write = db.writeTransaction(() async { + await db.customStatement("UPDATE users SET note='must rollback'"); + started.complete(); + await release.future; + }, gate: gate); + final failure = expectLater(write, throwsA(isA())); + await started.future; + await gate.run(() async {}, replacesData: true); + release.complete(); + await failure; + expect((await db.select(db.users).getSingle()).note, 'original'); + await gate.run(() async { + await db.writeTransaction( + () => db.customStatement("UPDATE users SET note='ordinary edit'"), + gate: gate, + ); + }); + expect((await db.select(db.users).getSingle()).note, 'ordinary edit'); + }, + ); + Future> backupJson() async => + jsonDecode( + utf8.decode( + await BackupCrypto(sodiumLoader: loadSodiumForTest).decrypt( + container: await service.createEncryptedBackup(password), + password: password, + ), + ), + ) + as Map; + Future previewJson( + Map value, + ) async => service.previewEncryptedBackup( + await BackupCrypto(sodiumLoader: loadSodiumForTest).encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(value))), + password: password, + ), + password, + ); + + test( + 'new durable fields reject malformed types and explicit freeze without first start before mutation', + () async { + final original = await backupJson(); + final before = await db.select(db.users).getSingle(); + for (final overrides in [ + {'startedAt': 123}, + {'startedAt': 'not-an-instant'}, + {'preparationFrozen': 'true'}, + {'startedAt': null, 'preparationFrozen': true}, + ]) { + final data = jsonDecode(jsonEncode(original)) as Map; + ((data['schedules'] as List).single as Map).addAll(overrides); + await expectLater(previewJson(data), throwsFormatException); + expect(await db.select(db.users).getSingle(), before); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_same', + ), + 'old', + ); + } + }, + ); + test( + 'wall-clock reversal preserves historical completion without creating a new outcome', + () async { + final data = await backupJson(); + final row = (data['schedules'] as List).single as Map; + row['finishedAt'] = '2029-12-30T00:00:00.000Z'; + row['doneStatus'] = 'normalEnd'; + row['scoreContributionRecorded'] = true; + (data['profile'] as Map)['eligibleOutcomeCount'] = 1; + (data['profile'] as Map)['onTimeOutcomeCount'] = 1; + await service.applyRestore(await previewJson(data)); + final restored = await db.select(db.schedules).getSingle(); + final profile = await db.select(db.users).getSingle(); + expect(restored.finishedAt!.isBefore(restored.startedAt!), isTrue); + expect(restored.isStarted, isFalse); + expect(restored.requiresStartConfirmation, isFalse); + expect(restored.scoreContributionRecorded, isTrue); + expect(profile.eligibleOutcomeCount, 1); + expect(profile.onTimeOutcomeCount, 1); + }, + ); + + test( + 'new first-start instant exports UTC and offset-equivalent import preserves epoch without changing civil time', + () async { + final data = await backupJson(); + final row = (data['schedules'] as List).single as Map; + final civil = row['civilTime']; + expect(row['startedAt'], '2029-12-31T00:00:00.000Z'); + row['startedAt'] = '2029-12-31T09:00:00.000+09:00'; + await service.applyRestore(await previewJson(data)); + expect( + (await db.select(db.schedules).getSingle()) + .startedAt! + .millisecondsSinceEpoch, + DateTime.utc(2029, 12, 31).millisecondsSinceEpoch, + ); + final exported = (await backupJson())['schedules'] as List; + expect(exported.single['startedAt'], '2029-12-31T00:00:00.000Z'); + expect(exported.single['civilTime'], civil); + }, + ); + for (final version in [1, 2]) { + test( + 'old format $version missing durable start fields keeps historical defaults', + () async { + final data = await backupJson(); + data['formatVersion'] = version; + final row = (data['schedules'] as List).single as Map; + row.remove('startedAt'); + row.remove('preparationFrozen'); + await service.applyRestore(await previewJson(data)); + final restored = (await db.select(db.schedules).getSingle()); + expect(restored.startedAt, isNull); + expect(restored.preparationFrozen, isFalse); + expect(restored.isStarted, isFalse); + }, + ); + } + test( + 'future backup version is rejected before any live or runtime mutation', + () async { + final data = await backupJson(); + data['formatVersion'] = 99; + final before = await db.select(db.users).getSingle(); + await expectLater(previewJson(data), throwsFormatException); + expect(await db.select(db.users).getSingle(), before); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_same', + ), + 'old', + ); + }, + ); + test( + 'template original creation and last edit instants survive encrypted portable roundtrip', + () async { + final created = DateTime.utc(2028, 1, 2, 3), + updated = DateTime.utc(2029, 4, 5, 6); + await db.preparationTemplateDao.put( + id: 'template', + name: 'Original', + preparation: const PreparationEntity(preparationStepList: []), + now: created, + ); + await db.preparationTemplateDao.put( + id: 'template', + name: 'Edited', + preparation: const PreparationEntity(preparationStepList: []), + now: updated, + ); + final data = await backupJson(); + expect( + (data['templates'] as List).single['createdAt'], + created.toIso8601String(), + ); + await service.applyRestore(await previewJson(data)); + final template = await db.preparationTemplateDao.getById('template'); + expect(template.createdAt.toUtc(), created); + expect(template.updatedAt.toUtc(), updated); + expect(template.name, 'Edited'); + (data['templates'] as List).single['updatedAt'] = + '2029-04-05T06:00:00.001Z'; + await expectLater(previewJson(data), throwsFormatException); + }, + ); + test( + 'cancel releases candidate without changing live profile or old runtime', + () async { + final before = await db.select(db.users).getSingle(); + final candidate = await preview(); + await candidate.dispose(); + await candidate.dispose(); + expect(await db.select(db.users).getSingle(), before); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_same', + ), + 'old', + ); + await expectLater( + service.applyRestore(candidate), + throwsA(isA()), + ); + }, + ); + test( + 'committed staging release failure keeps durable marker and cleanup retry cannot reapply', + () async { + var fail = true, releases = 0; + final custom = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: gate, + runtimeIdentity: identity, + cleanupPlatform: () => platform(), + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: () async { + final stage = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(stage, () async { + releases++; + if (fail) throw StateError('unlink fault'); + await stage.close(); + }); + }, + ); + final candidate = await custom.previewEncryptedBackup( + await service.createEncryptedBackup(password), + password, + ); + await custom.applyRestore(candidate); + final committed = await db.select(db.users).getSingle(); + expect(committed.restoreCleanupPending, isTrue); + expect(gate.isRecoveryPending, isTrue); + await expectLater( + candidate.dispose(), + throwsA(isA()), + ); + fail = false; + await custom.finishRestoreCleanup(); + final clean = await db.select(db.users).getSingle(); + expect(clean.storeIncarnation, committed.storeIncarnation); + expect(clean.dataRevision, committed.dataRevision); + expect(clean.restoreCleanupPending, isFalse); + await expectLater( + custom.applyRestore(candidate), + throwsA(isA()), + ); + expect(releases, 3); + }, + ); + test( + 'marker commit failure stays pending and retry preserves committed identity', + () async { + final candidate = await preview(); + await db.customStatement( + "CREATE TRIGGER fail_marker BEFORE UPDATE OF restore_cleanup_pending ON users WHEN NEW.restore_cleanup_pending=0 BEGIN SELECT RAISE(ABORT,'marker fault'); END", + ); + await service.applyRestore(candidate); + final committed = await db.select(db.users).getSingle(); + expect(committed.restoreCleanupPending, isTrue); + expect(gate.isRecoveryPending, isTrue); + await db.customStatement('DROP TRIGGER fail_marker'); + await service.finishRestoreCleanup(); + final clean = await db.select(db.users).getSingle(); + expect(clean.storeIncarnation, committed.storeIncarnation); + expect(clean.dataRevision, committed.dataRevision); + expect(clean.restoreCleanupPending, isFalse); + }, + ); +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1', buildNumber: '1'); +} + +class _Timed implements TimedPreparationRepository { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + @override + Future clearTimedPreparation(String scheduleId) async {} +} diff --git a/test/core/backup/restore_staging_cipher_test.dart b/test/core/backup/restore_staging_cipher_test.dart new file mode 100644 index 00000000..6e24cbcf --- /dev/null +++ b/test/core/backup/restore_staging_cipher_test.dart @@ -0,0 +1,392 @@ +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/sodium_test_loader.dart'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/open.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:drift/drift.dart' show Value; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (library != null) { + open.overrideForAll(() => DynamicLibrary.open(library)); + } + test( + 'staging exclusive collision preserves an unowned original file', + () async { + final root = await Directory.systemTemp.createTemp( + 'd05-stage-collision-', + ); + addTearDown(() => root.delete(recursive: true)); + final existing = File('${root.path}/collision.sqlite'); + await existing.writeAsString('unowned original'); + await expectLater( + RestoreStaging.create(root: root, attemptId: 'collision'), + throwsA(isA()), + ); + expect(await existing.readAsString(), 'unowned original'); + }, + ); + + test( + 'production staging refuses a plaintext SQLite engine', + () async { + final root = await Directory.systemTemp.createTemp( + 'ontime-staging-plain-', + ); + addTearDown(() => root.delete(recursive: true)); + await expectLater( + RestoreStaging.create(root: root), + throwsA(isA()), + ); + expect(await root.list().toList(), isEmpty); + }, + skip: library != null + ? 'Plaintext guard runs without SQLCipher override' + : false, + ); + test( + 'actual SQLCipher staging hides bytes and rejects missing/wrong keys', + () async { + final root = await Directory.systemTemp.createTemp( + 'ontime-staging-cipher-', + ); + addTearDown(() => root.delete(recursive: true)); + final stage = await RestoreStaging.create(root: root); + final version = await stage.database + .customSelect('PRAGMA cipher_version') + .getSingle(); + expect(version.data.values.single, isNotEmpty); + await stage.database + .into(stage.database.users) + .insert( + UsersCompanion.insert( + id: const Value('local-profile'), + spareTime: 7, + note: 'PRIVATE-STAGING-MARKER', + ), + ); + final file = (await root.list().toList()).whereType().firstWhere( + (f) => f.path.endsWith('.sqlite'), + ); + final bytes = await file.readAsBytes(); + expect( + utf8.decode(bytes, allowMalformed: true), + isNot(contains('PRIVATE-STAGING-MARKER')), + ); + expect( + utf8.decode(bytes.take(16).toList(), allowMalformed: true), + isNot(startsWith('SQLite format 3')), + ); + for (final key in [null, 'incorrect-key']) { + final raw = sqlite.sqlite3.open(file.path); + try { + if (key != null) raw.execute("PRAGMA key = '$key'"); + expect( + () => raw.select('SELECT note FROM users'), + throwsA(isA()), + ); + } finally { + raw.dispose(); + } + } + expect( + (await stage.database.select(stage.database.users).getSingle()).note, + 'PRIVATE-STAGING-MARKER', + ); + await stage.release(); + await stage.release(); + expect(await root.list().toList(), isEmpty); + }, + skip: library == null + ? 'Supply an actual SQLCipher shared library for encrypted host evidence' + : false, + ); + + test( + 'encrypted unlink fault is typed; retry and keyless orphan cleanup preserve active candidates and external files', + () async { + final root = await Directory.systemTemp.createTemp( + 'ontime-staging-fault-', + ); + addTearDown(() => root.delete(recursive: true)); + final stagingRoot = Directory('${root.path}/owned'); + var fail = true; + final stage = await RestoreStaging.create( + root: stagingRoot, + removeFile: (file) async { + if (fail) { + throw FileSystemException('injected unlink fault', file.path); + } + await file.delete(); + }, + ); + final stageFile = (await stagingRoot.list().toList()) + .whereType() + .firstWhere((f) => f.path.endsWith('.sqlite')); + final orphan = await stageFile.copy( + '${stagingRoot.path}/abandoned.sqlite', + ); + final external = File('${root.path}/external.backup'); + await external.writeAsString('external-sentinel'); + await Link('${stagingRoot.path}/foreign-link').create(external.path); + await RestoreStaging.cleanupAbandoned(root: stagingRoot); + expect(await orphan.exists(), isFalse); + expect(await external.readAsString(), 'external-sentinel'); + expect(await stageFile.exists(), isTrue); + await expectLater( + stage.release(), + throwsA( + isA().having( + (e) => e.cleanupError, + 'unlink cause', + isA(), + ), + ), + ); + expect(await stageFile.exists(), isTrue); + fail = false; + await stage.release(); + expect(await stagingRoot.list().toList(), isEmpty); + expect(await external.exists(), isTrue); + }, + skip: library == null + ? 'Requires actual SQLCipher for encrypted orphan evidence' + : false, + ); + + for (final failureStage in ['cancel', 'validation', 'commit']) { + test( + 'actual encrypted candidate $failureStage unlink fault preserves files and correct receipt ownership', + () async { + SharedPreferences.setMockInitialValues({ + 'early_start_session_same': 'old', + }); + final root = await Directory.systemTemp.createTemp( + 'a09-cipher-cleanup-', + ); + addTearDown(() => root.delete(recursive: true)); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'original', + ), + ); + final before = await db.select(db.users).getSingle(); + final gate = LocalDataOperationGate(); + addTearDown(gate.dispose); + var fail = false; + var restoreAttempt = false; + final processingOwner = BackupProcessingOwner(); + late RestoreStaging stage; + Future factory() async { + stage = await RestoreStaging.create( + root: Directory('${root.path}/owned'), + removeFile: (file) async { + if (fail) { + throw FileSystemException('injected unlink failure', file.path); + } + await file.delete(); + }, + ); + if (restoreAttempt && failureStage == 'validation') { + await stage.database.customStatement( + "CREATE TRIGGER corrupt_readback AFTER INSERT ON users BEGIN UPDATE users SET note='changed'; END", + ); + } + return stage; + } + + final backup = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: gate, + processingOwner: processingOwner, + ingestionFactory: (b) => BackupIngestionStore.create( + b, + root: Directory('${root.path}/ingestion'), + ), + stagingFactory: factory, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: () async {}, + ); + final bytes = await backup.createEncryptedBackup( + 'portable backup password', + ); + fail = true; + restoreAttempt = true; + BackupRestoreCandidate? candidate; + if (failureStage == 'validation') { + await expectLater( + backup.previewEncryptedBackup(bytes, 'portable backup password'), + throwsA( + isA().having( + (e) => e.originalError, + 'original validation failure', + isFormatException, + ), + ), + ); + } else { + candidate = await backup.previewEncryptedBackup( + bytes, + 'portable backup password', + ); + if (failureStage == 'cancel') { + await expectLater( + candidate.dispose(), + throwsA(isA()), + ); + } else { + await backup.applyRestore(candidate); + expect( + (await db.select(db.users).getSingle()).restoreCleanupPending, + isTrue, + ); + } + } + expect( + (await Directory( + '${root.path}/owned', + ).list().toList()).whereType(), + isNotEmpty, + ); + if (failureStage != 'commit') { + expect(await db.select(db.users).getSingle(), before); + expect( + (await SharedPreferences.getInstance()).getString( + 'early_start_session_same', + ), + 'old', + ); + } + final committed = await db.select(db.users).getSingle(); + fail = false; + if (failureStage == 'commit') { + await backup.finishRestoreCleanup(); + final clean = await db.select(db.users).getSingle(); + expect(clean.storeIncarnation, committed.storeIncarnation); + expect(clean.dataRevision, committed.dataRevision); + await expectLater( + backup.applyRestore(candidate!), + throwsA(isA()), + ); + } else { + await processingOwner.retryCleanup(); + expect(processingOwner.active, isNull); + } + expect(await Directory('${root.path}/owned').list().toList(), isEmpty); + }, + skip: library == null + ? 'Requires actual SQLCipher for candidate unlink fault evidence' + : false, + ); + } + test( + 'portable backup uses actual encrypted staging and restores encrypted live DB across reopen', + () async { + SharedPreferences.setMockInitialValues({ + 'early_start_session_orphan': 'old', + }); + final root = await Directory.systemTemp.createTemp( + 'ontime-cipher-roundtrip-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File('${root.path}/live.sqlite'); + AppDatabase openLive() => AppDatabase.forTesting( + NativeDatabase( + file, + setup: (raw) { + raw.execute("PRAGMA key = 'synthetic-test-installation-key'"); + }, + ), + ); + var db = openLive(); + final gate = LocalDataOperationGate(); + addTearDown(gate.dispose); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 12), + note: 'cipher-roundtrip', + ), + ); + final service = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: gate, + processingOwner: BackupProcessingOwner(), + ingestionFactory: (b) => BackupIngestionStore.create( + b, + root: Directory('${root.path}/ingestion'), + ), + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: () async {}, + stagingFactory: () => + RestoreStaging.create(root: Directory('${root.path}/staging')), + ); + final encrypted = await service.createEncryptedBackup( + 'portable backup password', + ); + final candidate = await service.previewEncryptedBackup( + encrypted, + 'portable backup password', + ); + expect( + (await Directory( + '${root.path}/staging', + ).list().toList()).whereType(), + isNotEmpty, + ); + final before = await db.select(db.users).getSingle(); + await service.applyRestore(candidate); + final restored = await db.select(db.users).getSingle(); + expect(restored.storeIncarnation, isNot(before.storeIncarnation)); + expect(restored.note, 'cipher-roundtrip'); + expect((await SharedPreferences.getInstance()).getKeys(), isEmpty); + expect(await Directory('${root.path}/staging').list().toList(), isEmpty); + await db.close(); + db = openLive(); + try { + final reopened = await db.select(db.users).getSingle(); + expect(reopened, restored); + expect(reopened.restoreCleanupPending, isFalse); + expect(reopened.rejectLegacyDelivery, isTrue); + } finally { + await db.close(); + } + }, + skip: library == null + ? 'Requires an actual SQLCipher library; separate host evidence' + : false, + ); +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1', buildNumber: '1'); +} diff --git a/test/core/backup/schedule_deletion_backup_test.dart b/test/core/backup/schedule_deletion_backup_test.dart new file mode 100644 index 00000000..ef758fb3 --- /dev/null +++ b/test/core/backup/schedule_deletion_backup_test.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_content.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import '../../helpers/schedule_deletion_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late ScheduleDeletionFixture f; + setUp(() async { + f = ScheduleDeletionFixture(); + await f.initialize(); + }); + tearDown(() => f.close()); + + test( + 'new exported content omits deleted history while old external bytes remain restorable', + () async { + await f.create('private-history'); + await f.create('retained-history'); + await f.outcome('private-history', ScheduleDoneStatus.normalEnd); + await f.outcome('retained-history', ScheduleDoneStatus.lateEnd); + await f.db.preparationUserDao.createPreparationUser( + f.preparation('shared-default'), + 'local-profile', + ); + await f.db.preparationTemplateDao.put( + id: 'shared-template', + name: 'Independent template', + preparation: f.preparation('shared-template'), + now: f.now, + ); + f.now = DateTime.utc(2031); + final before = await f.exportBytes(); + final directory = await Directory.systemTemp.createTemp( + 'ontime-u01-external-', + ); + addTearDown(() => directory.delete(recursive: true)); + // Portable plaintext snapshot fixture; no claim this is an encrypted file. + final external = File('${directory.path}/old-snapshot.json'); + await external.writeAsBytes(before, flush: true); + final oldHash = sha256.convert(await external.readAsBytes()); + final score = await f.score(); + + await f.aggregate.delete( + await f.aggregate.readForDeletion('private-history'), + ); + final after = await f.exportBytes(); + final text = utf8.decode(after); + final decoded = BackupContent.fromJson( + jsonDecode(text) as Map, + ); + + for (final sentinel in [ + 'private-history', + 'Name private-history', + 'Note private-history', + 'private-history first', + 'private-history last', + ]) { + expect( + text, + isNot(contains(sentinel)), + reason: 'Deleted owned content must not enter a new backup', + ); + } + expect(decoded.schedules.map((s) => s.id), ['retained-history']); + expect(decoded.profile.valueOrNull!.eligibleOutcomeCount, score.eligible); + expect(decoded.profile.valueOrNull!.onTimeOutcomeCount, score.onTime); + expect( + decoded.defaultPreparation.preparationStepList.map( + (s) => s.preparationName, + ), + contains('shared-default first'), + ); + expect(decoded.templates.single.id, 'shared-template'); + expect(sha256.convert(await external.readAsBytes()), oldHash); + expect(await external.readAsBytes(), before); + + final restored = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(restored.close); + final old = BackupContent.fromJson( + jsonDecode(utf8.decode(await external.readAsBytes())) + as Map, + ); + await old.writeTo(restored, pendingCleanup: false); + await old.validateReadBack(restored); + expect( + (await restored.scheduleDao.getScheduleById( + 'private-history', + )).schedule.scheduleName, + 'Name private-history', + ); + expect((await f.db.select(f.db.schedules).get()).map((s) => s.id), [ + 'retained-history', + ]); + }, + ); + + test( + 'deleted recurring override vanishes from portable definitions without losing shared rule or exclusion', + () async { + final at = DateTime.utc(2030, 1, 2, 10); + await f.recurring.create( + f.schedule('shared-series', at: at), + f.preparation('shared-rule'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: at, + timeZoneId: 'UTC', + count: 2, + ), + ); + final target = (await f.db.scheduleDao.getScheduleList()).first + .toScheduleEntity(); + await f.recurring.updateOccurrence( + target, + target.copyWith( + scheduleName: 'Private title', + scheduleNote: 'Private note', + ), + f.preparation('private-override'), + preparationChanged: true, + ); + final edited = (await f.db.scheduleDao.getScheduleById( + target.id, + )).toScheduleEntity(); + final ownedDefinition = edited.preparationDefinitionId!; + await f.outcome(edited.id, ScheduleDoneStatus.abnormalEnd); + f.now = DateTime.utc(2031); + final before = utf8.decode(await f.exportBytes()); + expect(before, contains('private-override first')); + expect(before, contains(ownedDefinition)); + + await f.aggregate.delete(await f.aggregate.readForDeletion(edited.id)); + final text = utf8.decode(await f.exportBytes()); + final content = BackupContent.fromJson( + jsonDecode(text) as Map, + ); + + for (final sentinel in [ + edited.id, + ownedDefinition, + 'Private title', + 'Private note', + 'private-override first', + 'private-override last', + ]) { + expect(text, isNot(contains(sentinel))); + } + expect(content.recurring.segments, hasLength(1)); + expect(content.recurring.definitions, hasLength(1)); + expect( + content.recurring.steps.map((s) => s.name), + contains('shared-rule first'), + ); + expect(content.recurring.exclusions, hasLength(1)); + final exclusion = content.recurring.exclusions.single; + expect(exclusion.segmentId, edited.recurringSegmentId); + expect(exclusion.slotKey, edited.recurringSlotKey); + expect(exclusion.ordinal, edited.recurringOrdinal); + expect(content.schedules, hasLength(1)); + final roundTrip = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(roundTrip.close); + await content.writeTo(roundTrip, pendingCleanup: false); + await content.validateReadBack(roundTrip); + }, + ); +} diff --git a/test/core/database/alarm_replacement_integration_test.dart b/test/core/database/alarm_replacement_integration_test.dart index 610b7578..d39cefe7 100644 --- a/test/core/database/alarm_replacement_integration_test.dart +++ b/test/core/database/alarm_replacement_integration_test.dart @@ -1,3 +1,5 @@ +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'package:on_time_front/core/database/local_reset_actions.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; @@ -44,6 +46,11 @@ void main() { _Metadata(), r.cancelAll, operationGate: r.gate, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: r.cancelAll.forDataReplacement, crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), ); Future candidate(BackupService service) async => @@ -92,7 +99,7 @@ void main() { ); test( - 'actual restore cancellation failure preserves original database and ownership', + 'returned cancellation failure commits restore but preserves pending ownership for cleanup-only retry', () async { final r = concurrency.Rig(); addTearDown(r.dispose); @@ -104,26 +111,19 @@ void main() { r.repository.schedules = [r.schedule('old')]; await r.reconcile(); r.fallback.throwOnCancelIds.add('old'); - await expectLater( - service.applyRestore(restore), - throwsA( - isA() - .having((e) => e.followUpPending, 'cleanup remains pending', true) - .having((e) => e.generation, 'actual claim generation', 1), - ), - ); - expect( - (await database.select(database.users).getSingle()).note, - 'current value', - ); + await service.applyRestore(restore); + final committed = await database.select(database.users).getSingle(); + expect(committed.note, 'backup value'); + expect(committed.restoreCleanupPending, true); expect(r.registry.records.single.cancellationPending, true); - expect(r.gate.isAvailable, true); + expect(r.gate.isRecoveryPending, true); r.fallback.throwOnCancelIds.clear(); - await service.applyRestore(restore); - expect( - (await database.select(database.users).getSingle()).note, - 'backup value', - ); + await service.finishRestoreCleanup(); + final after = await database.select(database.users).getSingle(); + expect(after.dataRevision, committed.dataRevision); + expect(after.storeIncarnation, committed.storeIncarnation); + expect(after.restoreCleanupPending, false); + expect(r.gate.isAvailable, true); }, ); @@ -143,6 +143,9 @@ void main() { keyStore: keys, closeDatabase: database.close, deleteFiles: () async {}, // In-memory Drift fixture owns no DB files. + removeCreationReceipt: () async {}, // Nor a first-creation receipt. + removePairKeys: + () async {}, // No pair files or slots in this fixture. clearDeliveries: () async {}, // Provider cancellation is exercised by r. clearLaunch: () async {}, diff --git a/test/core/database/cipher_capability_guard_test.dart b/test/core/database/cipher_capability_guard_test.dart new file mode 100644 index 00000000..8857bdcc --- /dev/null +++ b/test/core/database/cipher_capability_guard_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:sqlite3/sqlite3.dart'; + +// Branch-input evidence only. Actual engine and mobile proofs are separate. +void main() { + for (final entry in >>{ + 'no rows': [], + 'null': [ + [null], + ], + 'empty': [ + [''], + ], + 'whitespace': [ + [' '], + ], + 'nonstring': [ + [4], + ], + 'multiple rows': [ + ['4'], + ['4'], + ], + }.entries) { + test('${entry.key} capability refuses before key/schema/write', () { + final raw = _CapabilityOnlyDatabase( + ResultSet(['cipher_version'], null, entry.value), + ); + expect( + () => guardEncryptedDatabase( + raw, + '09' * 32, + role: DatabaseOpenRole.legacyStartup, + ), + throwsA(isA()), + ); + expect(raw.calls, ['PRAGMA cipher_version']); + }); + } + test('capability query error escapes before key/schema/write', () { + final raw = _CapabilityOnlyDatabase(null); + expect( + () => guardEncryptedDatabase( + raw, + '09' * 32, + role: DatabaseOpenRole.legacyStartup, + ), + throwsA(isA()), + ); + expect(raw.calls, ['PRAGMA cipher_version']); + }); +} + +class _CapabilityOnlyDatabase implements Database { + _CapabilityOnlyDatabase(this.result); + final ResultSet? result; + final calls = []; + @override + ResultSet select(String sql, [List parameters = const []]) { + calls.add(sql); + if (sql != 'PRAGMA cipher_version') { + throw StateError('Schema access forbidden'); + } + return result ?? (throw SqliteException(1, 'capability query fault')); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('Key/write/access forbidden: ${invocation.memberName}'); +} diff --git a/test/core/database/database_index_test.dart b/test/core/database/database_index_test.dart index 4f129839..764a04af 100644 --- a/test/core/database/database_index_test.dart +++ b/test/core/database/database_index_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; void main() { late AppDatabase database; @@ -30,11 +31,11 @@ void main() { ); test('rejects in-place upgrades across the one-way local cutover', () async { - expect(database.schemaVersion, 3); + expect(database.schemaVersion, 4); await expectLater( database.migration.onUpgrade(database.createMigrator(), 0, 1), - throwsStateError, + throwsA(isA()), ); }); } diff --git a/test/core/database/encrypted_schema_roles_test.dart b/test/core/database/encrypted_schema_roles_test.dart new file mode 100644 index 00000000..9c34ca10 --- /dev/null +++ b/test/core/database/encrypted_schema_roles_test.dart @@ -0,0 +1,252 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'package:on_time_front/core/database/recovery/encrypted_pair_database.dart'; +import 'package:on_time_front/core/database/recovery/pair_files.dart'; +import 'package:on_time_front/core/database/recovery/pair_key_store.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; +import 'package:sqlite3/open.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +const key = '0909090909090909090909090909090909090909090909090909090909090909'; +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (library != null) open.overrideForAll(() => DynamicLibrary.open(library)); + group( + 'actual encrypted schema role boundaries', + () { + for (final version in DatabaseSchemaContract.legacySupported) { + test( + 'v$version readonly legacy probe preserves file family/key; only authorized startup upgrades', + () async { + final dir = await Directory.systemTemp.createTemp('d04-roles-'); + addTearDown(() => dir.delete(recursive: true)); + final keys = PairKeyStore(); + const legacy = StorePair.legacy(); + final pair = StorePair.candidate( + '10000000-0000-4000-8000-000000000001', + ); + final encoded = base64UrlEncode(List.filled(32, 9)); + FlutterSecureStorage.setMockInitialValues({ + keys.slot(legacy): encoded, + keys.slot(pair): encoded, + }); + final files = PairFiles(dir, keys, protect: (_) async {}); + await files.prepareDirectories(); + final file = files.database(legacy); + final raw = sqlite.sqlite3.open(file.path); + guardEncryptedDatabase( + raw, + key, + role: DatabaseOpenRole.ownedCreation, + allowCreation: true, + ); + raw.execute( + File( + 'test/fixtures/database/schema_v$version.sql', + ).readAsStringSync(), + ); + raw.execute( + File( + 'test/fixtures/database/populated_local.sql', + ).readAsStringSync(), + ); + raw.dispose(); + await file.copy(files.database(pair).path); + final before = await _files(dir); + expect(await originalPairReadable(files, legacy), true); + expect( + await originalPairReadable(files, pair), + false, + reason: + 'Historical pair lineage is invalid; current synthetic profile also lacks reject-legacy flag', + ); + expect(await _files(dir), before); + expect(await keys.raw(legacy), encoded); + expect(await keys.raw(pair), encoded); + if (version != DatabaseSchemaContract.current) { + await expectLater( + openEncryptedPair(files, pair), + throwsA(isA()), + ); + await expectLater( + openEncryptedPair(files, pair, startupMigration: true), + throwsA(isA()), + ); + expect(await _files(dir), before); + } + final db = AppDatabase.forTesting( + NativeDatabase( + file, + setup: (r) => guardEncryptedDatabase( + r, + key, + role: DatabaseOpenRole.legacyStartup, + ), + ), + ); + expect( + (await db.customSelect('PRAGMA user_version').getSingle()) + .data + .values + .single, + DatabaseSchemaContract.current, + ); + await db.close(); + final reopened = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + guardEncryptedDatabase( + reopened, + key, + role: DatabaseOpenRole.legacyStartup, + ); + expect( + reopened.select('SELECT note FROM users').single['note'], + '합성 프로필 공백', + ); + reopened.dispose(); + expect(await keys.raw(legacy), encoded); + }, + ); + } + for (final mutation in [ + 'future', + 'nonempty-zero', + 'unknown-table', + 'missing-trigger', + 'changed-literal', + 'invalid-fk', + 'invalid-flags', + ]) { + test( + '$mutation actual file is refused and byte-preserved by readonly original probe', + () async { + final dir = await Directory.systemTemp.createTemp('d04-preserve-'); + addTearDown(() => dir.delete(recursive: true)); + final keys = PairKeyStore(); + const pair = StorePair.legacy(); + final encoded = base64UrlEncode(List.filled(32, 9)); + FlutterSecureStorage.setMockInitialValues({ + keys.slot(pair): encoded, + }); + final files = PairFiles(dir, keys, protect: (_) async {}); + final file = files.database(pair); + final raw = sqlite.sqlite3.open(file.path); + guardEncryptedDatabase( + raw, + key, + role: DatabaseOpenRole.ownedCreation, + allowCreation: true, + ); + raw.execute( + File('test/fixtures/database/schema_v4.sql').readAsStringSync(), + ); + raw.execute( + File( + 'test/fixtures/database/populated_local.sql', + ).readAsStringSync(), + ); + switch (mutation) { + case 'future': + raw.userVersion = DatabaseSchemaContract.current + 1; + case 'nonempty-zero': + raw.userVersion = 0; + case 'unknown-table': + raw.execute('CREATE TABLE sqlitex_probe(value TEXT)'); + case 'missing-trigger': + raw.execute('DROP TRIGGER users_identity'); + case 'changed-literal': + final sql = + raw + .select( + "SELECT sql FROM sqlite_master WHERE name='users_identity'", + ) + .single['sql'] + as String; + raw.execute('DROP TRIGGER users_identity'); + raw.execute(sql.replaceAll('IS NULL', 'IS "NULL"')); + case 'invalid-fk': + raw.execute("UPDATE schedules SET place_id='missing'"); + case 'invalid-flags': + raw.execute('PRAGMA ignore_check_constraints=ON'); + raw.execute('UPDATE users SET reject_legacy_delivery=2'); + } + raw.dispose(); + final before = await _files(dir); + expect(await originalPairReadable(files, pair), false); + expect(await _files(dir), before); + expect(await keys.raw(pair), encoded); + final db = AppDatabase.forTesting( + NativeDatabase( + file, + setup: (r) => guardEncryptedDatabase( + r, + key, + role: DatabaseOpenRole.legacyStartup, + ), + ), + ); + await expectLater( + db.customSelect('SELECT 1').get(), + throwsA(isA()), + ); + await db.close(); + expect(await _files(dir), before); + expect(await keys.raw(pair), encoded); + }, + ); + } + test('empty schema requires explicit creation authority and role', () { + for (final role in DatabaseOpenRole.values) { + for (final allowed in [false, true]) { + final raw = sqlite.sqlite3.openInMemory(); + try { + void action() => guardEncryptedDatabase( + raw, + key, + role: role, + allowCreation: allowed, + ); + if (allowed && + [ + DatabaseOpenRole.ownedCreation, + DatabaseOpenRole.legacyStartup, + ].contains(role)) { + action(); + } else { + expect(action, throwsA(isA())); + } + expect(raw.userVersion, 0); + expect(raw.select(DatabaseSchemaContract.objectsQuery), isEmpty); + } finally { + raw.dispose(); + } + } + } + }); + }, + skip: library == null + ? 'Requires actual host SQLCipher; not mobile evidence' + : false, + ); +} + +Future>> _files(Directory directory) async { + final out = >{}; + await for (final entity in directory.list(recursive: true)) { + if (entity is File) { + out[entity.path.substring(directory.path.length)] = await entity + .readAsBytes(); + } + } + return out; +} diff --git a/test/core/database/historical_row_image_test.dart b/test/core/database/historical_row_image_test.dart new file mode 100644 index 00000000..60133afb --- /dev/null +++ b/test/core/database/historical_row_image_test.dart @@ -0,0 +1,109 @@ +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +void main() { + for (final version in DatabaseSchemaContract.legacySupported) { + test( + 'historical v$version preserves every durable row image across upgrade and two reopens', + () async { + final dir = await Directory.systemTemp.createTemp('ontime-d04-rows-'); + addTearDown(() => dir.delete(recursive: true)); + final file = File('${dir.path}/store.sqlite'); + final original = sqlite.sqlite3.open(file.path); + original.execute( + File( + 'test/fixtures/database/schema_v$version.sql', + ).readAsStringSync(), + ); + original.execute( + File('test/fixtures/database/populated_local.sql').readAsStringSync(), + ); + if (version >= 2) { + original.execute( + File( + 'test/fixtures/database/populated_recurring.sql', + ).readAsStringSync(), + ); + } + final image = >>{}; + final columns = >{}; + for (final table in original.select( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*' ORDER BY name", + )) { + final name = table['name'] as String; + columns[name] = original + .select('PRAGMA table_info("$name")') + .map((r) => r['name'] as String) + .toList(); + image[name] = original + .select('SELECT * FROM "$name" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(); + expect( + image[name], + isNotEmpty, + reason: 'Every historical table must have actual synthetic rows', + ); + } + expect(image.length, version == 1 ? 7 : 11); + original.dispose(); + Map>>? currentImage; + for (var opening = 0; opening < 2; opening++) { + final db = AppDatabase.forTesting(NativeDatabase(file)); + await db.customSelect('SELECT 1').get(); + await db.close(); + final reopened = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + try { + expect(reopened.userVersion, DatabaseSchemaContract.current); + for (final entry in columns.entries) { + final fields = entry.value.map((c) => '"$c"').join(','); + final actual = reopened + .select('SELECT $fields FROM "${entry.key}" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(); + expect( + actual, + image[entry.key], + reason: + 'v$version ${entry.key} opening $opening original values', + ); + } + final after = >>{}; + for (final table in reopened.select( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*' ORDER BY name", + )) { + final name = table['name'] as String; + after[name] = reopened + .select('SELECT * FROM "$name" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(); + } + if (currentImage != null) { + expect( + after, + currentImage, + reason: + 'Second reopen must not regenerate identities or alter revisions', + ); + } + currentImage = after; + expect(reopened.select('PRAGMA foreign_key_check'), isEmpty); + expect( + reopened.select('PRAGMA integrity_check').single.values.single, + 'ok', + ); + } finally { + reopened.dispose(); + } + } + }, + ); + } +} diff --git a/test/core/database/initial_store_cipher_test.dart b/test/core/database/initial_store_cipher_test.dart new file mode 100644 index 00000000..0aa39a6b --- /dev/null +++ b/test/core/database/initial_store_cipher_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/initial_store_guard_native.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'package:sqlite3/open.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (library != null) open.overrideForAll(() => DynamicLibrary.open(library)); + test( + 'first creation receipt finishes only after real cipher/schema; reopen keeps original bytes and key', + () async { + FlutterSecureStorage.setMockInitialValues({}); + final root = await Directory.systemTemp.createTemp('ontime-d01-cipher-'); + addTearDown(() => root.delete(recursive: true)); + final file = File('${root.path}/ontime_local_v1.sqlite'); + final keys = InstallationKeyStore(); + InitialStoreGuard guard() => + InitialStoreGuard(file, keys, protect: (_) async {}); + final first = await guard().prepareOpen(); + String hex(List key) => + key.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + AppDatabase database(List key) => AppDatabase.forTesting( + NativeDatabase( + file, + setup: (raw) { + raw.execute('PRAGMA key = "x\'${hex(key)}\'"'); + expect(raw.select('PRAGMA cipher_version'), isNotEmpty); + }, + ), + ); + final initial = database(first); + await initial + .into(initial.users) + .insert( + UsersCompanion.insert(spareTime: 7, note: 'D01-SYNTHETIC-PRIVATE'), + ); + await initial.close(); + expect(await guard().receiptFile.exists(), true); + // Simulated process interruption after cipher/schema, before completion. + final second = await guard().prepareOpen(); + expect(base64Encode(second) == base64Encode(first), true); + final reopened = database(second); + expect( + (await reopened.select(reopened.users).getSingle()).note, + 'D01-SYNTHETIC-PRIVATE', + ); + await reopened.close(); + await guard().completeVerifiedCreation(); + expect(await guard().receiptFile.exists(), false); + final bytes = await file.readAsBytes(); + expect( + utf8.decode(bytes, allowMalformed: true), + isNot(contains('D01-SYNTHETIC-PRIVATE')), + ); + expect( + utf8.decode(bytes.take(16).toList(), allowMalformed: true), + isNot(startsWith('SQLite format 3')), + ); + for (final wrongKey in [null, 'wrong-key']) { + final raw = sqlite.sqlite3.open(file.path); + try { + if (wrongKey != null) raw.execute("PRAGMA key = '$wrongKey'"); + expect( + () => raw.select('SELECT note FROM users'), + throwsA(isA()), + ); + } finally { + raw.dispose(); + } + } + await keys.delete(); + await expectLater( + guard().prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect( + base64Encode(await file.readAsBytes()) == base64Encode(bytes), + true, + ); + expect(await keys.exists(), false); + }, + skip: library == null + ? 'Requires actual SQLCipher host library; not mobile keystore evidence' + : false, + ); +} diff --git a/test/core/database/initial_store_guard_test.dart b/test/core/database/initial_store_guard_test.dart new file mode 100644 index 00000000..e148cd74 --- /dev/null +++ b/test/core/database/initial_store_guard_test.dart @@ -0,0 +1,350 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/core/database/local_reset_actions.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/initial_store_guard_native.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late File file; + late InstallationKeyStore keys; + late InitialStoreGuard guard; + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + root = await Directory.systemTemp.createTemp('ontime-d01-'); + file = File('${root.path}/ontime_local_v1.sqlite'); + keys = InstallationKeyStore(); + guard = InitialStoreGuard(file, keys, protect: (_) async {}); + }); + tearDown(() async => root.delete(recursive: true)); + for (final checkpoint in [1, 2, 3, 4, 5]) { + test( + 'creation interruption at protection checkpoint $checkpoint preserves owned evidence', + () async { + var calls = 0; + final interrupted = InitialStoreGuard( + file, + keys, + protect: (_) async { + if (++calls == checkpoint) { + throw StateError('injected interruption'); + } + }, + ); + await expectLater( + interrupted.prepareOpen().then((_) => true), + throwsA(isA()), + ); + final priorKey = await keys.readExisting(); + final priorReceipt = await guard.receiptFile.exists() + ? await guard.receiptFile.readAsString() + : null; + if (checkpoint == 2) { + // An uncommitted temp-only reservation is not ownership authority. + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await file.exists(), false); + expect(await keys.exists(), false); + } else { + final key = await guard.prepareOpen(); + if (priorKey != null) { + expect(base64Encode(key) == base64Encode(priorKey), true); + } + if (priorReceipt != null) { + expect( + jsonDecode(await guard.receiptFile.readAsString())['owner'], + jsonDecode(priorReceipt)['owner'], + ); + } + expect(await file.length(), 0); + } + }, + ); + } + test( + 'secure key read failure is not absence and cannot create a receipt or file', + () async { + final storage = _Unreadable(); + final guarded = InitialStoreGuard( + file, + InstallationKeyStore(storage: storage), + protect: (_) async {}, + ); + await expectLater( + guarded.prepareOpen().then((_) => true), + throwsA(isA()), + ); + await expectLater( + guarded.requireNoLocalEvidence(), + throwsA(isA()), + ); + expect(storage.writes, 0); + expect(await root.list().toList(), isEmpty); + }, + ); + test( + 'malformed ownership metadata preserves bytes and cannot create a key', + () async { + await guard.receiptFile.writeAsString('unreadable prior ownership'); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect( + await guard.receiptFile.readAsString(), + 'unreadable prior ownership', + ); + expect(await keys.exists(), false); + expect(await file.exists(), false); + }, + ); + test( + 'explicit reset database stage removes and reads back fixed creation receipts', + () async { + await guard.prepareOpen(); + await guard.temporaryReceipt.writeAsString('interrupted metadata'); + final actions = DeviceLocalResetActions( + deleteFiles: () async { + await file.delete(); + }, + removeCreationReceipt: guard.removeReceipt, + removePairKeys: + () async {}, // This fixed-store fixture has no pair slots. + ); + await actions.perform(ResetStep.database); + expect(await guard.receiptFile.exists(), false); + expect(await guard.temporaryReceipt.exists(), false); + // The distinct key stage remains responsible for key removal. + expect(await keys.exists(), true); + await actions.perform(ResetStep.key); + expect(await keys.exists(), false); + }, + ); + test( + 'completion unlink failure keeps verified ownership and retries the same key/file', + () async { + final originalKey = await guard.prepareOpen(); + // Cipher/schema proof is covered separately with actual SQLCipher. Here the + // caller has crossed that boundary and injects only the metadata unlink fault. + await file.writeAsBytes([9, 8, 7]); + final fault = InitialStoreGuard( + file, + keys, + protect: (_) async {}, + removeCompletedReceipt: (_) async => + throw FileSystemException('unlink unconfirmed'), + ); + await expectLater( + fault.completeVerifiedCreation(), + throwsA(isA()), + ); + expect( + jsonDecode(await guard.receiptFile.readAsString())['stage'], + 'verified', + ); + final resumed = await guard.prepareOpen(); + expect(base64Encode(originalKey) == base64Encode(resumed), true); + expect(await file.readAsBytes(), [9, 8, 7]); + await guard.completeVerifiedCreation(); + expect(await guard.receiptFile.exists(), false); + expect(await file.readAsBytes(), [9, 8, 7]); + }, + ); + for (final suffix in ['', '-wal', '-shm', '-journal']) { + test( + 'unowned $suffix family evidence prevents cutover and key creation', + () async { + final member = File('${file.path}$suffix'); + await member.writeAsBytes([1, 2, 3]); + await expectLater( + guard.requireNoLocalEvidence(), + throwsA(isA()), + ); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await member.readAsBytes(), [1, 2, 3]); + expect(await keys.exists(), false); + expect(await guard.receiptFile.exists(), false); + }, + ); + } + test('unowned key-only and zero-byte file are preserved', () async { + final key = await keys.createVerified(); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + await file.create(); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await keys.readExisting(), key); + expect(await file.length(), 0); + }); + test( + 'new creation writes metadata ownership before key/file and resumes same key', + () async { + final a = await guard.prepareOpen(); + final metadata = + jsonDecode(await guard.receiptFile.readAsString()) as Map; + expect(metadata.keys.toSet(), { + 'version', + 'owner', + 'stage', + 'store', + 'keySlot', + }); + expect(metadata['stage'], 'keyReady'); + expect(await file.length(), 0); + final restarted = InitialStoreGuard(file, keys, protect: (_) async {}); + expect(await restarted.prepareOpen(), a); + expect(await keys.readExisting(), a); + await expectLater( + restarted.completeVerifiedCreation(), + throwsA(isA()), + ); + }, + ); + test( + 'receipt never authorizes replacing missing key for existing file', + () async { + await guard.prepareOpen(); + await keys.delete(); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await keys.exists(), false); + expect(await file.length(), 0); + }, + ); + test( + 'uncertain key write reads back same value without a second write', + () async { + final storage = _WrittenThenError(); + final keys = InstallationKeyStore(storage: storage); + final key = await keys.createVerified(); + expect(await keys.readExisting(), key); + expect(storage.writes, 1); + }, + ); + test( + 'activation evidence prevents fresh classification without modifying fixed store', + () async { + final blocked = InitialStoreGuard( + file, + keys, + protect: (_) async {}, + activationEvidence: () async => true, + ); + await expectLater( + blocked.prepareOpen(), + throwsA(isA()), + ); + expect(await keys.exists(), false); + expect(await root.list().toList(), isEmpty); + }, + ); + test( + 'verified receipt with missing store cannot regress to empty creation', + () async { + final key = await guard.prepareOpen(); + final metadata = + jsonDecode(await guard.receiptFile.readAsString()) + as Map; + metadata['stage'] = 'verified'; + await guard.receiptFile.writeAsString(jsonEncode(metadata)); + await file.delete(); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await file.exists(), false); + expect(await keys.readExisting(), key); + expect( + jsonDecode(await guard.receiptFile.readAsString())['stage'], + 'verified', + ); + }, + ); + test('reserved receipt does not own an unexpected zero-byte file', () async { + await guard.prepareOpen(); + final metadata = + jsonDecode(await guard.receiptFile.readAsString()) + as Map; + metadata['stage'] = 'reserved'; + await guard.receiptFile.writeAsString(jsonEncode(metadata)); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await file.length(), 0); + }); + test('receipt symlink is not followed', () async { + final sentinel = File('${root.path}/sentinel'); + await sentinel.writeAsString('preserve'); + await Link(guard.receiptFile.path).create(sentinel.path); + await expectLater( + guard.prepareOpen().then((_) => true), + throwsA(isA()), + ); + expect(await sentinel.readAsString(), 'preserve'); + }); +} + +class _WrittenThenError extends FlutterSecureStorage { + int writes = 0; + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + writes++; + await super.write(key: key, value: value, iOptions: iOptions); + throw StateError('response lost'); + } +} + +class _Unreadable extends FlutterSecureStorage { + int writes = 0; + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async => throw PlatformException(code: 'locked'); + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + writes++; + } +} diff --git a/test/core/database/local_data_lifecycle_startup_test.dart b/test/core/database/local_data_lifecycle_startup_test.dart new file mode 100644 index 00000000..f1661cd8 --- /dev/null +++ b/test/core/database/local_data_lifecycle_startup_test.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/local_data_lifecycle.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const secure = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + const paths = MethodChannel('plugins.flutter.io/path_provider'); + const cutover = 'ontime_local_only_cutover_v1'; + const keyName = 'ontime_local_database_key_v1'; + late Directory root; + late Map storage; + late List writes; + var failRead = false; + var ignoreMarkerWrite = false; + var ignoreTokenDelete = false; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + setUp(() async { + root = await Directory.systemTemp.createTemp('ontime-d01-bootstrap-'); + storage = {}; + writes = []; + failRead = false; + ignoreMarkerWrite = false; + ignoreTokenDelete = false; + SharedPreferences.setMockInitialValues({'untouched': 'synthetic'}); + messenger.setMockMethodCallHandler(paths, (_) async => root.path); + messenger.setMockMethodCallHandler(secure, (call) async { + final args = call.arguments as Map; + final key = args['key'] as String; + switch (call.method) { + case 'read': + if (key == cutover && failRead) { + throw PlatformException(code: 'locked'); + } + return storage[key]; + case 'write': + writes.add(key); + if (!(key == cutover && ignoreMarkerWrite)) { + storage[key] = args['value'] as String; + } + return null; + case 'delete': + if (!(ignoreTokenDelete && key == 'accessToken')) storage.remove(key); + return null; + default: + throw StateError('Unexpected secure operation'); + } + }); + }); + tearDown(() async { + messenger.setMockMethodCallHandler(paths, null); + messenger.setMockMethodCallHandler(secure, null); + await root.delete(recursive: true); + }); + test( + 'verified-pair marker repair cannot complete while a legacy token deletion is unconfirmed', + () async { + storage['accessToken'] = 'synthetic'; + ignoreTokenDelete = true; + await expectLater( + LocalDataLifecycle.repairCutoverAfterVerifiedRestore(), + throwsA(isA()), + ); + expect(storage['accessToken'], 'synthetic'); + expect(storage[cutover], null); + ignoreTokenDelete = false; + await LocalDataLifecycle.repairCutoverAfterVerifiedRestore(); + expect(storage['accessToken'], null); + expect(storage[cutover], 'complete'); + }, + ); + test( + 'new explicit reset cannot supersede unresolved restore authority', + () async { + final gate = LocalDataOperationGate.shared; + gate.setRecoveryPending(true); + try { + await expectLater( + LocalDataLifecycle.beginReset(), + throwsA(isA()), + ); + expect(storage, isEmpty); + expect(writes, isEmpty); + } finally { + gate.setRecoveryPending(false); + } + }, + ); + for (final mode in ['missing', 'unknown', 'read-failed']) { + test( + 'actual bootstrap $mode marker preserves existing local DB/key and performs no cutover write', + () async { + final file = File('${root.path}/ontime_local_v1.sqlite'); + await file.writeAsBytes([11, 22, 33]); + final key = base64UrlEncode(List.filled(32, 7)); + storage[keyName] = key; + if (mode == 'unknown') storage[cutover] = 'unknown-version'; + failRead = mode == 'read-failed'; + await expectLater( + LocalDataLifecycle.bootstrap(), + throwsA(isA()), + ); + expect(await file.readAsBytes(), [11, 22, 33]); + expect(storage[keyName] == key, true); + expect(writes, isEmpty); + expect( + (await SharedPreferences.getInstance()).getString('untouched'), + 'synthetic', + ); + }, + ); + } + test( + 'verified legacy-only cutover removes legacy family and reads complete marker back', + () async { + final legacy = File('${root.path}/my_database.sqlite'); + await legacy.writeAsBytes([1, 2]); + await LocalDataLifecycle.bootstrap(); + expect(await legacy.exists(), false); + expect(storage[cutover], 'complete'); + expect(writes, [cutover]); + expect(storage.containsKey(keyName), false); + expect(await File('${root.path}/ontime_local_v1.sqlite').exists(), false); + expect((await SharedPreferences.getInstance()).getKeys(), isEmpty); + }, + ); + test( + 'ignored cutover marker write is unconfirmed and never creates local data/key', + () async { + ignoreMarkerWrite = true; + await expectLater( + LocalDataLifecycle.bootstrap(), + throwsA(isA()), + ); + expect(storage.containsKey(cutover), false); + expect(storage.containsKey(keyName), false); + expect(await File('${root.path}/ontime_local_v1.sqlite').exists(), false); + }, + ); +} diff --git a/test/core/database/local_reset_actions_test.dart b/test/core/database/local_reset_actions_test.dart index 4d44dd0f..ace49a0e 100644 --- a/test/core/database/local_reset_actions_test.dart +++ b/test/core/database/local_reset_actions_test.dart @@ -43,14 +43,20 @@ void main() { final storage = _IgnoredDeletes(); final keys = InstallationKeyStore(storage: storage); final original = await keys.getOrCreate(); - final actions = DeviceLocalResetActions(keyStore: keys); + final actions = DeviceLocalResetActions( + keyStore: keys, + removePairKeys: () async {}, + ); await expectLater( actions.perform(ResetStep.key), throwsA(isA()), ); expect(await keys.getOrCreate(), original); final working = InstallationKeyStore(); - await DeviceLocalResetActions(keyStore: working).perform(ResetStep.key); + await DeviceLocalResetActions( + keyStore: working, + removePairKeys: () async {}, + ).perform(ResetStep.key); expect(await working.exists(), false); }, ); diff --git a/test/core/database/migration_atomic_boundary_test.dart b/test/core/database/migration_atomic_boundary_test.dart new file mode 100644 index 00000000..2fe9cbed --- /dev/null +++ b/test/core/database/migration_atomic_boundary_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +// The real SQLite connection becomes read-only only after the migration callback +// has committed. Drift's own later version write must fail with SQLITE_READONLY. +class _ReadOnlyAfterMigration extends AppDatabase { + _ReadOnlyAfterMigration(super.executor) : super.forTesting(); + + @override + MigrationStrategy get migration { + final base = super.migration; + return MigrationStrategy( + onCreate: base.onCreate, + onUpgrade: base.onUpgrade, + beforeOpen: (details) async { + await base.beforeOpen?.call(details); + if (details.hadUpgrade) { + await customStatement('PRAGMA query_only = ON'); + } + }, + ); + } +} + +void main() { + test( + 'external version-write failure leaves a complete current schema/version', + () async { + final directory = await Directory.systemTemp.createTemp( + 'ontime-d04-version-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/store.sqlite'); + final initial = sqlite.sqlite3.open(file.path); + initial.execute( + File('test/fixtures/database/schema_v2.sql').readAsStringSync(), + ); + initial.execute( + "INSERT INTO users(id,spare_time,note) VALUES('local-profile',17,'D04 synthetic unchanged')", + ); + initial.dispose(); + + final db = _ReadOnlyAfterMigration(NativeDatabase(file)); + await expectLater( + db.customSelect('SELECT 1').get(), + throwsA( + isA().having( + (e) => e.resultCode, + 'SQLITE_READONLY', + 8, + ), + ), + ); + await db.close(); + final reopened = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + try { + final columns = reopened + .select('PRAGMA table_info(users)') + .map((r) => r['name']) + .toList(); + expect(columns, contains('restore_cleanup_pending')); + expect( + reopened.select('SELECT note FROM users').single['note'], + 'D04 synthetic unchanged', + ); + expect( + reopened.userVersion, + 4, + reason: + 'DDL and version must commit together even when Drift later repeats the version write', + ); + } finally { + reopened.dispose(); + } + }, + ); +} diff --git a/test/core/database/migration_checkpoint_test.dart b/test/core/database/migration_checkpoint_test.dart new file mode 100644 index 00000000..c3d200bc --- /dev/null +++ b/test/core/database/migration_checkpoint_test.dart @@ -0,0 +1,147 @@ +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +class _MigrationCheckpoint extends AppDatabase { + _MigrationCheckpoint(super.executor, {this.cut, this.after = false}) + : super.forTesting(); + final int? cut; + final bool after; + final writes = []; + bool hit = false; + @override + Future customStatement(String statement, [List? args]) async { + final observed = + statement.startsWith('CREATE ') || + statement.startsWith('ALTER ') || + statement.startsWith('UPDATE ') || + statement.startsWith('PRAGMA user_version ='); + final index = observed ? writes.length : -1; + if (observed) writes.add(statement); + Future fault() async { + hit = true; + // Real SQLite error on the transaction connection, not a Dart throw. + await super.customStatement('SELECT d04_nonexistent_migration_column'); + } + + if (observed && index == cut && !after) await fault(); + await super.customStatement(statement, args); + if (observed && index == cut && after) await fault(); + } +} + +void main() { + for (final version in DatabaseSchemaContract.legacySupported.where( + (v) => v < DatabaseSchemaContract.current, + )) { + test( + 'v$version every migration write before/after SQLite fault rolls back all historical data', + () async { + final dir = await Directory.systemTemp.createTemp( + 'ontime-d04-all-writes-', + ); + addTearDown(() => dir.delete(recursive: true)); + final file = File('${dir.path}/store.sqlite'); + Map>> seed() { + final raw = sqlite.sqlite3.open(file.path); + try { + raw.execute( + File( + 'test/fixtures/database/schema_v$version.sql', + ).readAsStringSync(), + ); + raw.execute( + File( + 'test/fixtures/database/populated_local.sql', + ).readAsStringSync(), + ); + if (version >= 2) { + raw.execute( + File( + 'test/fixtures/database/populated_recurring.sql', + ).readAsStringSync(), + ); + } + return { + for (final row in raw.select( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*'", + )) + row['name'] as String: raw + .select('SELECT * FROM "${row['name']}" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(), + }; + } finally { + raw.dispose(); + } + } + + seed(); + final trace = _MigrationCheckpoint(NativeDatabase(file)); + await trace.customSelect('SELECT 1').get(); + final count = trace.writes.length; + expect(count, greaterThan(0)); + await trace.close(); + await file.delete(); + for (var point = 0; point < count; point++) { + for (final after in [false, true]) { + final image = seed(); + final db = _MigrationCheckpoint( + NativeDatabase(file), + cut: point, + after: after, + ); + await expectLater( + db.customSelect('SELECT 1').get(), + throwsA( + isA().having( + (e) => e.resultCode, + 'SQLITE_ERROR', + 1, + ), + ), + reason: 'v$version point$point after$after', + ); + expect(db.hit, true); + await db.close(); + final raw = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + try { + expect(raw.userVersion, version); + DatabaseSchemaContract.validate( + version, + raw.select(DatabaseSchemaContract.objectsQuery), + ); + for (final entry in image.entries) { + expect( + raw + .select('SELECT * FROM "${entry.key}" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(), + entry.value, + reason: 'v$version point$point after$after ${entry.key}', + ); + } + expect(raw.select('PRAGMA foreign_key_check'), isEmpty); + } finally { + raw.dispose(); + } + await file.delete(); + } + } + // Counts are derived from the real migration, so newly added writes cannot + // silently miss the before/after fault matrix. + // ignore: avoid_print + print( + 'v$version: $count actual migration writes, ${count * 2} SQLite fault/reopen cases', + ); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); + } +} diff --git a/test/core/database/migration_sqlite_fault_test.dart b/test/core/database/migration_sqlite_fault_test.dart new file mode 100644 index 00000000..fdb1a0cd --- /dev/null +++ b/test/core/database/migration_sqlite_fault_test.dart @@ -0,0 +1,142 @@ +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +class _FaultDatabase extends AppDatabase { + _FaultDatabase(super.executor, this.kind, this.raw, this.reader) + : super.forTesting(); + final String kind; + final sqlite.Database Function() raw; + final sqlite.Database reader; + bool injected = false; + @override + Future customStatement(String statement, [List? args]) async { + if (!injected && + kind == 'constraint' && + statement.startsWith('UPDATE users SET store_incarnation')) { + injected = true; + await super.customStatement( + "CREATE TRIGGER d04_fault BEFORE UPDATE ON users BEGIN SELECT RAISE(ABORT, 'synthetic migration constraint'); END", + ); + } else if (!injected && + kind == 'readonly' && + statement.startsWith('ALTER TABLE')) { + injected = true; + raw().execute('PRAGMA query_only=ON'); + } else if (!injected && + kind == 'full' && + statement.startsWith('ALTER TABLE')) { + injected = true; + final pages = raw().select('PRAGMA page_count').single.values.single; + raw().execute('PRAGMA max_page_count=$pages'); + // Actual SQLite allocation failure in the same migration transaction. + // This test-only write forces disk-page demand without filling the host. + await super.customStatement('UPDATE users SET note=zeroblob(1048576)'); + } + await super.customStatement(statement, args); + if (!injected && + kind == 'locked' && + statement.startsWith('PRAGMA user_version =')) { + injected = true; + reader.execute('BEGIN'); + reader.select('SELECT note FROM users'); // Holds an actual shared lock. + } + } +} + +void main() { + for (final entry in { + 'constraint': 19, + 'readonly': 8, + 'full': 13, + 'locked': 5, + }.entries) { + test( + 'real SQLite ${entry.key} failure preserves historical schema and rows', + () async { + final dir = await Directory.systemTemp.createTemp('ontime-d04-fault-'); + addTearDown(() => dir.delete(recursive: true)); + final file = File('${dir.path}/store.sqlite'); + final initial = sqlite.sqlite3.open(file.path); + initial.execute( + File('test/fixtures/database/schema_v2.sql').readAsStringSync(), + ); + initial.execute( + "INSERT INTO users(id,spare_time,note) VALUES('local-profile',17,'preserve synthetic value')", + ); + initial.dispose(); + late sqlite.Database connection; + final reader = sqlite.sqlite3.open(file.path); + final db = _FaultDatabase( + NativeDatabase(file, setup: (r) => connection = r), + entry.key, + () => connection, + reader, + ); + try { + await expectLater( + db.customSelect('SELECT 1').get(), + throwsA( + isA().having( + (e) => e.resultCode, + 'actual SQLite error code', + entry.value, + ), + ), + ); + expect(db.injected, true); + } finally { + reader.dispose(); + await db.close(); + } + final reopened = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + try { + expect(reopened.userVersion, 2); + expect( + reopened.select('SELECT note FROM users').single['note'], + 'preserve synthetic value', + ); + expect( + reopened.select('PRAGMA table_info(users)').map((r) => r['name']), + isNot(contains('store_incarnation')), + ); + expect( + reopened.select( + "SELECT name FROM sqlite_master WHERE name='d04_fault'", + ), + isEmpty, + ); + expect( + reopened.select('PRAGMA integrity_check').single.values.single, + 'ok', + ); + } finally { + reopened.dispose(); + } + final retry = AppDatabase.forTesting(NativeDatabase(file)); + try { + expect( + (await retry.customSelect('PRAGMA user_version').getSingle()) + .data + .values + .single, + DatabaseSchemaContract.current, + ); + expect( + (await retry.customSelect('SELECT note FROM users').getSingle()) + .read('note'), + 'preserve synthetic value', + ); + } finally { + await retry.close(); + } + }, + ); + } +} diff --git a/test/core/database/native_process_identity_test.dart b/test/core/database/native_process_identity_test.dart new file mode 100644 index 00000000..5c57fa04 --- /dev/null +++ b/test/core/database/native_process_identity_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/recovery/native_process_identity.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('on_time_front/native_alarm'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + test( + 'native opaque process identity is consumed unchanged across calls', + () async { + const nonce = '10000000-0000-4000-8000-000000000001'; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getProcessIdentity'); + expect(call.arguments, null); + return nonce; + }); + expect(await readNativeProcessIdentity(), nonce); + expect(await readNativeProcessIdentity(), nonce); + }, + ); + for (final value in [ + null, + '', + 'device-id', + '10000000-0000-0000-8000-000000000001', + ]) { + test( + 'missing or invalid native identity never invents a restart: $value', + () async { + messenger.setMockMethodCallHandler(channel, (_) async => value); + await expectLater( + readNativeProcessIdentity(), + throwsA(isA()), + ); + }, + ); + } + test('unavailable channel remains unknown', () async { + messenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + await expectLater( + readNativeProcessIdentity(), + throwsA(isA()), + ); + }); +} diff --git a/test/core/database/recovery_pair_authority_test.dart b/test/core/database/recovery_pair_authority_test.dart new file mode 100644 index 00000000..082270b8 --- /dev/null +++ b/test/core/database/recovery_pair_authority_test.dart @@ -0,0 +1,315 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:on_time_front/core/database/recovery/pair_files.dart'; +import 'package:on_time_front/core/database/recovery/pair_key_store.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late PairFiles files; + final target = StorePair.candidate('00000000-0000-4000-8000-000000000001'); + final second = StorePair.candidate('00000000-0000-4000-8000-000000000002'); + const legacy = StorePair.legacy(); + const process = '10000000-0000-4000-8000-000000000001'; + PairRecord record(PairStage stage) => PairRecord( + target: target, + original: legacy, + originProcess: process, + stage: stage, + originalEvidence: '0' * 64, + runtimeIdentity: '1' * 32, + ); + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + root = await Directory.systemTemp.createTemp('d02-authority-'); + files = PairFiles(root, PairKeyStore(), protect: (_) async {}); + }); + tearDown(() => root.delete(recursive: true)); + test('no pair evidence delegates to existing D01 classification', () async { + expect(await files.selected(), legacy); + }); + for (final bad in [ + '../../victim', + '/tmp/victim', + 'legacy.sqlite', + '123', + '00000000-0000-0000-0000-000000000001', + ]) { + test( + 'rejects non-owned descriptor $bad', + () => expect( + () => StorePair.candidate(bad), + throwsA(isA()), + ), + ); + } + for (final name in ['ontime_recovery_pairs', 'ontime_pair_state']) { + test('parent symlink $name cannot read or delete external bytes', () async { + final outside = await Directory.systemTemp.createTemp('d02-outside-'); + addTearDown(() => outside.delete(recursive: true)); + final sentinel = File('${outside.path}/${target.id}.sqlite'); + await sentinel.writeAsString('external-original'); + await File( + '${outside.path}/active-v1.json', + ).writeAsString(files.manifestBytes(target)); + await Link('${root.path}/$name').create(outside.path); + await expectLater( + files.removeFamily(target), + throwsA(isA()), + ); + await expectLater( + files.readManifest(), + throwsA(isA()), + ); + await expectLater( + files.selected(), + throwsA(isA()), + ); + expect(await sentinel.readAsString(), 'external-original'); + expect(await File('${outside.path}/active-v1.json').exists(), true); + }); + } + test( + 'reservation ownership precedes keys and never selects candidate', + () async { + await files.writeRecord(record(PairStage.reserved)); + expect(await files.keys.read(target), null); + expect(await files.selected(), legacy); + final key = await files.keys.create(target); + expect(key.length, 32); + await expectLater( + files.keys.create(target), + throwsA(isA()), + ); + expect(await files.selected(), legacy); + }, + ); + test('history without manifest is not a fresh or legacy fallback', () async { + await files.keys.markHistory(); + await expectLater( + files.selected(), + throwsA(isA()), + ); + }); + test('target manifest requires confirmed lineage', () async { + await files.writeRecord(record(PairStage.validated)); + await files.publish(target); + await expectLater( + files.selected(), + throwsA(isA()), + ); + await files.writeRecord(record(PairStage.confirmed)); + expect(await files.selected(), target); + }); + test('journal cannot silently select a different active pair', () async { + await files.writeRecord(record(PairStage.activated)); + await files.publish(second); + await expectLater( + files.selected(), + throwsA(isA()), + ); + }); + for (final raw in [ + '{', + '{"version":2,"pair":"${target.id}"}', + '{"version":1,"pair":"../../victim"}', + ]) { + test('invalid manifest remains untouched: $raw', () async { + await files.prepareDirectories(); + await files.manifest.writeAsString(raw); + await expectLater( + files.publish(target), + throwsA(isA()), + ); + expect(await files.manifest.readAsString(), raw); + }); + } + test('ambiguous pending manifest cannot open the old selection', () async { + await files.writeRecord(record(PairStage.activated)); + await files.publish(target); + await File( + '${files.manifest.path}.pending', + ).writeAsString(files.manifestBytes(second)); + await expectLater( + files.selected(), + throwsA(isA()), + ); + await expectLater( + files.publish(target), + throwsA(isA()), + ); + expect(await files.readManifest(), target); + }); + test('same-size encrypted bytes change invalidates currentness', () async { + final main = files.database(legacy); + await main.writeAsString('original'); + final priorTime = await main.lastModified(); + final before = await files.evidence(legacy); + await main.writeAsString('replaced'); + await main.setLastModified(priorTime); + expect(await files.evidence(legacy), isNot(before)); + }); + test('record interrupted after rename remains authoritative', () async { + var once = true; + final fault = PairFiles( + root, + files.keys, + protect: (_) async {}, + checkpoint: (phase) async { + if (phase == 'record.reserved.renamed' && once) { + once = false; + throw StateError('lost response'); + } + }, + ); + await expectLater( + fault.writeRecord(record(PairStage.reserved)), + throwsStateError, + ); + expect((await files.readRecord())!.stage, PairStage.reserved); + expect(await files.keys.read(target), null); + }); + for (final afterDelete in [false, true]) { + test( + 'reset retains slot receipt across history delete failure afterDelete=$afterDelete', + () async { + final storage = _HistoryDeleteFault()..afterDelete = afterDelete; + final reset = PairFiles( + root, + PairKeyStore(storage: storage), + protect: (_) async {}, + ); + await reset.writeRecord(record(PairStage.activated)); + await reset.keys.create(target); + await reset.keys.markHistory(); + await reset.publish(target); + await reset.database(target).writeAsString('owned'); + await reset.resetFiles(); + storage.armed = true; + await expectLater(reset.resetKeysAndMetadata(), throwsStateError); + expect(await reset.keys.raw(target), null); + expect(await reset.resetCompletion.exists(), true); + await expectLater( + reset.selected(), + throwsA(isA()), + ); + storage.armed = false; + await reset.resetKeysAndMetadata(); + expect(await reset.keys.hasHistory(), false); + expect(await reset.resetCompletion.exists(), false); + expect(await reset.database(target).exists(), false); + expect(await reset.selected(), legacy); + }, + ); + } + for (final step in [ + 'active-v1.json.pending', + 'active-v1.json', + 'activation-v1.json.pending', + 'activation-v1.json', + ]) { + test( + 'reset resumes after metadata removal $step without losing slot ownership', + () async { + var armed = true; + final reset = PairFiles( + root, + files.keys, + protect: (_) async {}, + checkpoint: (phase) async { + if (armed && phase == 'resetMetadata.$step.removed') { + throw StateError('interrupted'); + } + }, + ); + await reset.writeRecord(record(PairStage.activated)); + await reset.keys.create(target); + await reset.keys.markHistory(); + await reset.publish(target); + await reset.database(target).writeAsString('owned'); + await reset.resetFiles(); + await expectLater(reset.resetKeysAndMetadata(), throwsStateError); + await expectLater( + reset.selected(), + throwsA(isA()), + ); + armed = false; + await reset.resetKeysAndMetadata(); + expect(await reset.keys.hasHistory(), false); + expect(await reset.keys.raw(target), null); + expect(await reset.selected(), legacy); + }, + ); + } + for (final slots in [ + [], + [target.id], + ['legacy', 'legacy'], + ['legacy', target.id, second.id, '00000000-0000-4000-8000-000000000003'], + ]) { + test('malformed reset inventory $slots cannot erase ownership', () async { + await files.writeRecord(record(PairStage.activated)); + await files.publish(target); + await files.keys.create(target); + await files.keys.markHistory(); + final raw = jsonEncode({'version': 1, 'slots': slots}); + await files.resetCompletion.writeAsString(raw); + await expectLater( + files.resetKeysAndMetadata(), + throwsA(isA()), + ); + expect(await files.keys.raw(target), isNotNull); + expect(await files.keys.hasHistory(), true); + expect(await files.resetCompletion.readAsString(), raw); + }); + } + test('future pending record is not overwritten by an older writer', () async { + await files.writeRecord(record(PairStage.reserved)); + final raw = record(PairStage.confirmed).encode()..['version'] = 2; + await File('${files.record.path}.pending').writeAsString(jsonEncode(raw)); + await expectLater( + files.writeRecord(record(PairStage.validated)), + throwsA(isA()), + ); + expect( + jsonDecode( + await File('${files.record.path}.pending').readAsString(), + )['version'], + 2, + ); + }); +} + +class _HistoryDeleteFault extends FlutterSecureStorage { + bool armed = false; + bool afterDelete = false; + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (armed && key == PairKeyStore.historyKey && !afterDelete) { + throw StateError('delete unavailable'); + } + await super.delete( + key: key, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + if (armed && key == PairKeyStore.historyKey && afterDelete) { + throw StateError('response lost'); + } + } +} diff --git a/test/core/database/recovery_restore_service_test.dart b/test/core/database/recovery_restore_service_test.dart new file mode 100644 index 00000000..0659834e --- /dev/null +++ b/test/core/database/recovery_restore_service_test.dart @@ -0,0 +1,1064 @@ +import '../backup/backup_validation_contract_test.dart' show validBackup; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import '../../helpers/noop_alarm_cleanup.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/local_reset_result.dart'; +import 'package:on_time_front/presentation/startup/screens/local_startup_gate.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/open.dart'; +import 'package:on_time_front/core/backup/backup_content.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/recovery/encrypted_pair_database.dart'; +import 'package:on_time_front/core/database/recovery/pair_files.dart'; +import 'package:on_time_front/core/database/recovery/pair_key_store.dart'; +import 'package:on_time_front/core/database/recovery/recovery_restore_service.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const origin = '10000000-0000-4000-8000-000000000001'; + const next = '10000000-0000-4000-8000-000000000002'; + const password = 'portable backup password'; + const legacy = StorePair.legacy(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + late Directory directory; + late PairFiles files; + late BackupProcessingOwner processingOwner; + late LocalDataOperationGate gate; + late AlarmOperationCoordinator owner; + var process = origin; + var repairs = 0; + final crypto = BackupCrypto(); + late Uint8List backup; + Future Function(String)? fault; + RecoveryRestoreService service() => RecoveryRestoreService( + files, + crypto: crypto, + processingOwner: processingOwner, + ingestionFactory: (b) => BackupIngestionStore.create( + b, + root: Directory('${directory.path}/ingestion'), + ), + gate: gate, + owner: owner, + processIdentity: () async => process, + cleanupPlatform: () async {}, + repairCutover: () async { + repairs++; + }, + ); + setUpAll(() async { + if (library != null) { + open.overrideForAll(() => DynamicLibrary.open(library)); + } + final db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'restored private note', + ), + ); + final content = await BackupContent.capture( + db, + cutoff: DateTime.utc(2030), + sourceAppVersion: 'test', + sourcePlatform: 'android', + ); + backup = await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(content.toJson()))), + password: password, + ); + await db.close(); + }); + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({}); + directory = await Directory.systemTemp.createTemp('d02-service-'); + processingOwner = BackupProcessingOwner(); + fault = null; + files = PairFiles( + directory, + PairKeyStore(), + protect: (_) async {}, + checkpoint: (step) async => fault?.call(step), + ); + gate = LocalDataOperationGate(); + owner = AlarmOperationCoordinator(gate); + process = origin; + repairs = 0; + await files + .database(legacy) + .writeAsString('damaged original encrypted bytes'); + }); + tearDown(() async { + owner.dispose(); + gate.dispose(); + await directory.delete(recursive: true); + }); + test( + 'known original manifest cannot progress confirmed intent by query alone', + () async { + final a = StorePair.candidate('00000000-0000-4000-8000-000000000001'); + final b = StorePair.candidate('00000000-0000-4000-8000-000000000002'); + await files.writeRecord( + PairRecord( + target: b, + original: a, + originProcess: origin, + stage: PairStage.confirmed, + originalEvidence: await files.evidence(a), + runtimeIdentity: '1' * 32, + ), + ); + await files.publish(a); + await files.keys.markHistory(); + process = next; + final recovery = service(); + for (var i = 0; i < 2; i++) { + final receipt = await recovery.resume(); + expect(receipt.disposition, BackupCommitDisposition.notCommitted); + expect(receipt.followUpPending, true); + expect(await files.readManifest(), a); + } + expect(gate.isRecoveryPending, true); + }, + ); + test( + 'existing reset intent forbids candidate allocation under the shared owner', + () async { + final state = await owner.journal.read(); + state.reset = ResetPhase.pending; + await owner.journal.save(state); + await expectLater( + service().previewBytes(backup, password), + throwsA(isA()), + ); + expect(await files.readRecord(), null); + expect( + await files.database(legacy).readAsString(), + 'damaged original encrypted bytes', + ); + }, + ); + group( + 'real host SQLCipher', + () { + test( + 'recovery cold reopen and activation preserve foreign civil gap raw text', + () async { + final input = validBackup(); + input['schedules'][0]['civilTime'] = '2026-03-08T02:30:00.123456'; + final encrypted = await crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(input))), + password: password, + ); + final recovery = service(); + final candidate = await recovery.previewBytes(encrypted, password); + var db = await openEncryptedPair(files, candidate.record.target); + expect( + (await db + .customSelect('SELECT schedule_time FROM schedules') + .getSingle()) + .read('schedule_time'), + '2026-03-08T02:30:00.123456', + ); + await db.close(); + expect( + (await recovery.activate(candidate)).disposition, + BackupCommitDisposition.committed, + ); + db = await openEncryptedPair(files, candidate.record.target); + expect( + (await db + .customSelect('SELECT schedule_time FROM schedules') + .getSingle()) + .read('schedule_time'), + '2026-03-08T02:30:00.123456', + ); + await db.close(); + expect( + await files.database(legacy).readAsString(), + 'damaged original encrypted bytes', + ); + }, + ); + Future ready() async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + process = next; + await service().prepareStartup(); + return (await files.readRecord())!; + } + + for (final failure in ['before', 'after', 'readBack']) { + test( + 'first history marker $failure failure keeps exact intent and resumes safely', + () async { + final storage = _HistoryFault(); + files = PairFiles( + directory, + PairKeyStore(storage: storage), + protect: (_) async {}, + ); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + storage.mode = failure; + final receipt = await recovery.activate(candidate); + if (failure == 'after') { + expect(receipt.disposition, BackupCommitDisposition.committed); + } else { + expect(receipt.disposition, BackupCommitDisposition.undetermined); + expect(await files.readManifest(), null); + } + expect(storage.writes, 1); + expect(await files.database(legacy).exists(), true); + storage.mode = ''; + process = next; + if (failure == 'before') { + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(await files.readManifest(), null); + } + expect((await service().resume()).followUpPending, false); + expect(await files.readManifest(), candidate.record.target); + }, + ); + } + for (final marker in ['future', '2']) { + test( + 'invalid history $marker is preserved and never overwritten', + () async { + final storage = _HistoryFault(); + files = PairFiles( + directory, + PairKeyStore(storage: storage), + protect: (_) async {}, + ); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + storage.mode = 'before'; + expect( + (await recovery.activate(candidate)).disposition, + BackupCommitDisposition.undetermined, + ); + storage.mode = ''; + await files.keys.storage.write( + key: PairKeyStore.historyKey, + value: marker, + ); + await expectLater( + files.keys.markHistory(), + throwsA(isA()), + ); + process = next; + expect( + (await service().resume()).disposition, + BackupCommitDisposition.undetermined, + ); + expect( + await files.keys.storage.read(key: PairKeyStore.historyKey), + marker, + ); + expect(await files.readManifest(), null); + }, + ); + } + test( + 'missing history after prior activation cannot use first-activation marker repair', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + await files.manifest.delete(); + await files.keys.removeHistory(); + process = next; + expect( + (await service().resume()).disposition, + BackupCommitDisposition.undetermined, + ); + expect(await files.keys.hasHistory(), false); + expect(await files.readManifest(), null); + }, + ); + test( + 'nonlegacy original cannot recreate missing history during explicit resume', + () async { + final a = await ready(); + await files.database(a.target).writeAsString('damaged'); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + fault = (step) async { + if (step == 'manifest.written') throw StateError('failed'); + }; + await recovery.activate(candidate); + fault = null; + await files.manifest.delete(); + await files.keys.removeHistory(); + process = '10000000-0000-4000-8000-000000000003'; + expect( + (await service().resume()).disposition, + BackupCommitDisposition.undetermined, + ); + expect(await files.keys.hasHistory(), false); + expect(await files.readManifest(), null); + expect(await files.database(a.target).exists(), true); + }, + ); + test( + 'duplicate explicit first-marker resume owns one outstanding storage Future', + () async { + final storage = _HistoryFault(); + files = PairFiles( + directory, + PairKeyStore(storage: storage), + protect: (_) async {}, + ); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + storage.mode = 'before'; + await recovery.activate(candidate); + storage.mode = ''; + process = next; + final entered = Completer(); + final unblock = Completer(); + storage.beforeHistoryWrite = () async { + entered.complete(); + await unblock.future; + }; + final first = recovery.resume(); + await entered.future; + final second = recovery.resume(); + expect(identical(first, second), true); + expect(storage.writes, 2); + expect(await files.readManifest(), null); + expect(gate.isRecoveryPending, true); + unblock.complete(); + expect((await first).followUpPending, false); + await second; + }, + ); + test( + 'readable legacy with rejectLegacy false is preserved and requires normal A09 confirmation', + () async { + final key = await InstallationKeyStore().getOrCreate(); + await files.database(legacy).writeAsBytes([]); + final hex = key + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final db = AppDatabase.forTesting( + NativeDatabase( + files.database(legacy), + setup: (raw) { + raw.execute('PRAGMA key = "x\'$hex\'"'); + }, + ), + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'normal legacy', + ), + ); + expect( + (await db.select(db.users).getSingle()).rejectLegacyDelivery, + false, + ); + await db.close(); + final original = await files.evidence(legacy); + expect(await originalPairReadable(files, legacy), true); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await expectLater( + recovery.activate(candidate), + throwsA(isA()), + ); + await candidate.dispose(); + expect(await files.evidence(legacy), original); + expect(await files.readManifest(), null); + }, + ); + for (final damage in ['invalidflags', 'foreign']) { + test( + 'ready $damage can finish a second complete encrypted recovery', + () async { + final a = await ready(); + final db = await openEncryptedPair(files, a.target); + if (damage == 'invalidflags') { + await db.customStatement( + 'UPDATE users SET reject_legacy_delivery = 0', + ); + } + if (damage == 'foreign') { + await db.customStatement('PRAGMA foreign_keys=OFF'); + await db.customStatement( + 'CREATE TABLE broken_relation (id INTEGER REFERENCES users(id))', + ); + await db.customStatement( + "INSERT INTO broken_relation VALUES ('absent')", + ); + } + await db.close(); + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(await originalPairReadable(files, a.target), false); + final original = await files.evidence(a.target); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + final applied = await recovery.activate(candidate); + expect(applied.disposition, BackupCommitDisposition.committed); + expect(await files.evidence(a.target), original); + process = '10000000-0000-4000-8000-000000000003'; + await service().prepareStartup(); + expect(await files.selected(), candidate.record.target); + expect(await files.database(a.target).exists(), false); + expect(await files.keys.raw(a.target), null); + }, + ); + } + for (final unresolved in [false, true]) { + testWidgets( + 'actual service startup result routes unresolved=$unresolved to the correct pre-DI actions', + (tester) async { + tester.binding.platformDispatcher.localesTestValue = const [ + Locale('en'), + ]; + addTearDown( + tester.binding.platformDispatcher.clearLocalesTestValue, + ); + await tester.runAsync(() async { + if (unresolved) { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + fault = (step) async { + if (step == 'manifest.written') { + throw StateError('interrupted'); + } + }; + await recovery.activate(candidate); + fault = null; + process = next; + } else { + final state = await ready(); + await files + .database(state.target) + .writeAsString( + 'new corruption after prior complete recovery', + ); + } + }); + final failure = await tester.runAsync(() async { + try { + await service().prepareStartup(); + return null; + } catch (cause) { + return cause; + } + }); + expect( + failure, + unresolved + ? isA() + : isA(), + ); + var normalApp = 0; + await tester.pumpWidget( + LocalStartupGate( + // Pass the real service outcome across the widget fake clock; + // no substitute service failure or fabricated receipt is used. + prepare: () async => throw failure!, + recovery: () async => service(), + ready: () { + normalApp++; + return const SizedBox(); + }, + beginReset: () async => const LocalResetResult( + intentRecorded: false, + completed: {}, + ), + retryReset: () async => const LocalResetResult( + intentRecorded: false, + completed: {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(normalApp, 0); + if (unresolved) { + expect(find.text('Continue recovery'), findsOneWidget); + expect(find.text('Choose backup file'), findsNothing); + expect(find.text('Delete all local data'), findsNothing); + } else { + expect(find.text('Restore an OnTime Backup'), findsOneWidget); + await tester.tap(find.text('Restore an OnTime Backup')); + await tester.pumpAndSettle(); + expect(find.text('Choose backup file'), findsOneWidget); + } + await tester.pumpWidget(const SizedBox()); + }, + ); + } + for (final suffix in ['-wal', '-shm', '-journal']) { + test( + 'ready target $suffix symlink refuses open and preserves external sentinel', + () async { + final state = await ready(); + final repairsBefore = repairs; + final outside = await Directory.systemTemp.createTemp( + 'd02-sidecar-outside-', + ); + addTearDown(() => outside.delete(recursive: true)); + final sentinel = File('${outside.path}/sentinel'); + await sentinel.writeAsString('outside encrypted sentinel'); + final link = Link('${files.database(state.target).path}$suffix'); + await link.create(sentinel.path); + final original = await files.database(state.target).readAsBytes(); + await expectLater( + openEncryptedPair(files, state.target), + throwsA(isA()), + ); + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(await sentinel.readAsString(), 'outside encrypted sentinel'); + expect(await link.target(), sentinel.path); + expect(await files.database(state.target).readAsBytes(), original); + expect(repairs, repairsBefore); + }, + ); + } + test( + 'normal A09 restore rotates runtime inside ready pair without rewriting manifest or key', + () async { + final state = await ready(); + final manifest = await files.manifest.readAsBytes(); + final key = await files.keys.raw(state.target); + final db = await openEncryptedPair(files, state.target); + final normal = BackupService( + db, + _Metadata(), + NoopAlarmCleanup(), + crypto: crypto, + operationGate: gate, + stagingFactory: memoryRestoreStaging, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: () async {}, + ); + final candidate = await normal.previewEncryptedBackup( + backup, + password, + ); + await normal.applyRestore(candidate); + await candidate.dispose(); + expect( + (await db.select(db.users).getSingle()).storeIncarnation, + isNot(state.runtimeIdentity), + ); + await db.close(); + await service().prepareStartup(); + expect(await files.manifest.readAsBytes(), manifest); + expect(await files.keys.raw(state.target), key); + expect(gate.isAvailable, true); + }, + ); + for (final damage in [ + 'missing', + 'zero', + 'keymissing', + 'invalidflags', + 'foreign', + 'futureSchema', + ]) { + test( + 'ready $damage is preserved as current-store-unavailable, permits new recovery and never repairs marker', + () async { + final state = await ready(); + final beforeRepairs = repairs; + final file = files.database(state.target); + if (damage == 'missing') await file.delete(); + if (damage == 'zero') await file.writeAsBytes([]); + if (damage == 'keymissing') await files.keys.remove(state.target); + if (damage == 'invalidflags' || + damage == 'foreign' || + damage == 'futureSchema') { + final db = await openEncryptedPair(files, state.target); + if (damage == 'invalidflags') { + await db.customStatement( + 'UPDATE users SET reject_legacy_delivery = 0', + ); + } + if (damage == 'foreign') { + await db.customStatement('PRAGMA foreign_keys=OFF'); + await db.customStatement( + 'CREATE TABLE fault_child (id INTEGER REFERENCES users(id))', + ); + await db.customStatement( + "INSERT INTO fault_child VALUES ('missing-parent')", + ); + } + if (damage == 'futureSchema') { + await db.customStatement('PRAGMA user_version=999'); + } + await db.close(); + } + final before = await files.evidence(state.target); + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(repairs, beforeRepairs); + expect(gate.isRecoveryPending, false); + expect(await files.evidence(state.target), before); + if (damage == 'missing') expect(await file.exists(), false); + if (damage == 'zero') expect(await file.length(), 0); + final candidate = await service().previewBytes(backup, password); + expect(candidate.record.original, state.target); + await candidate.dispose(); + }, + ); + } + test( + 'ready manifest loss is undetermined, never committed or a fresh recovery', + () async { + await ready(); + await files.manifest.delete(); + await expectLater( + service().prepareStartup(), + throwsA( + isA().having( + (e) => e.receipt.disposition, + 'disposition', + BackupCommitDisposition.undetermined, + ), + ), + ); + expect(gate.isRecoveryPending, true); + }, + ); + for (final step in [ + 'record.aborting.written', + 'record.aborting.renamed', + 'abort.manifestPending.removed', + 'family..removed', + 'abort.key.removed', + 'record.cancelled.written', + 'record.cancelled.renamed', + ]) { + test( + 'known-original abort resumes owned cleanup after $step', + () async { + final a = await ready(); + await files.database(a.target).writeAsString('newly damaged'); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + final oldEvidence = await files.evidence(a.target); + fault = (phase) async { + if (phase == 'manifest.written') { + throw StateError('publish failed'); + } + }; + expect( + (await recovery.activate(candidate)).disposition, + BackupCommitDisposition.notCommitted, + ); + fault = (phase) async { + if (phase == step) throw StateError('abort failed'); + }; + final pending = await recovery.abort(); + expect(pending.followUpPending, true); + expect(gate.isRecoveryPending, true); + expect(await files.evidence(a.target), oldEvidence); + fault = null; + final result = await service().abort(); + expect(result.disposition, BackupCommitDisposition.notCommitted); + expect(result.followUpPending, false); + expect(await files.keys.raw(candidate.record.target), null); + expect( + await files.database(candidate.record.target).exists(), + false, + ); + expect(await files.readManifest(), a.target); + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + final again = await service().previewBytes(backup, password); + await again.dispose(); + }, + ); + } + test( + 'already-applied history cannot be aborted even if manifest is replaced by original', + () async { + final a = await ready(); + await files.database(a.target).writeAsString('damaged'); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + await files.manifest.writeAsString(files.manifestBytes(a.target)); + expect( + (await recovery.abort()).disposition, + BackupCommitDisposition.undetermined, + ); + expect(await files.database(candidate.record.target).exists(), true); + expect(await files.keys.raw(candidate.record.target), isNotNull); + }, + ); + test( + 'original identity change refuses abort and preserves candidate', + () async { + final a = await ready(); + await files.database(a.target).writeAsString('damaged'); + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + fault = (phase) async { + if (phase == 'manifest.written') throw StateError('publish failed'); + }; + await recovery.activate(candidate); + fault = null; + await files.database(a.target).writeAsString('changed'); + expect( + (await recovery.abort()).disposition, + BackupCommitDisposition.undetermined, + ); + expect(await files.database(candidate.record.target).exists(), true); + }, + ); + test( + 'closed damaged original is preserved through encrypted preview and cancellation', + () async { + final before = await files.evidence(legacy); + final candidate = await service().previewBytes(backup, password); + final target = candidate.record.target; + final bytes = await files.database(target).readAsBytes(); + expect( + utf8.decode(bytes.take(16).toList(), allowMalformed: true), + isNot('SQLite format 3\x00'), + ); + final db = await openEncryptedPair(files, target); + expect( + (await db.select(db.users).getSingle()).note, + 'restored private note', + ); + await db.close(); + expect(await files.evidence(legacy), before); + await candidate.dispose(); + expect(await files.database(target).exists(), false); + expect(await files.keys.raw(target), null); + expect(await files.evidence(legacy), before); + expect(await files.selected(), legacy); + }, + ); + test( + 'same process preserves retired pair; new process verifies and completes cleanup', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + final target = candidate.record.target; + final receipt = await recovery.activate(candidate); + expect(receipt.recoveryFollowUp, RecoveryFollowUp.awaitingNewProcess); + expect(await files.database(legacy).exists(), true); + expect(repairs, 0); + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(await files.database(legacy).exists(), true); + process = next; + await service().prepareStartup(); + expect(await files.database(legacy).exists(), false); + expect(await files.selected(), target); + expect((await files.readRecord())!.stage, PairStage.ready); + expect(gate.isRecoveryPending, false); + final db = await openEncryptedPair(files, target); + final row = await db.select(db.users).getSingle(); + expect(row.note, 'restored private note'); + expect(row.restoreCleanupPending, false); + await db.close(); + }, + ); + test( + 'history marker interruption requires explicit new-process same-target resume', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + fault = (step) async { + if (step == 'manifest.written') throw StateError('interrupted'); + }; + final receipt = await recovery.activate(candidate); + expect(receipt.disposition, BackupCommitDisposition.undetermined); + expect(await files.readManifest(), null); + fault = null; + process = next; + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(await files.readManifest(), null); + final resumed = await service().resume(); + expect(resumed.disposition, BackupCommitDisposition.committed); + expect(resumed.followUpPending, false); + expect(await files.readManifest(), candidate.record.target); + }, + ); + for (final interruption in ['manifest.renamed', 'manifest.readBack']) { + test( + 'commit response loss at $interruption never reimports and retains original until restart', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + final before = await files + .database(candidate.record.target) + .readAsBytes(); + fault = (step) async { + if (step == interruption) throw StateError('lost response'); + }; + expect( + (await recovery.activate(candidate)).disposition, + BackupCommitDisposition.committed, + ); + expect(await files.database(legacy).exists(), true); + expect( + await files.database(candidate.record.target).readAsBytes(), + before, + ); + fault = null; + process = next; + await service().prepareStartup(); + expect(await files.database(legacy).exists(), false); + }, + ); + } + test( + 'manifest lost after selection only republishes same target after explicit new-process resume', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + await files.manifest.delete(); + process = next; + await expectLater( + service().prepareStartup(), + throwsA(isA()), + ); + expect(await files.readManifest(), null); + expect((await service().resume()).followUpPending, false); + expect(await files.readManifest(), candidate.record.target); + }, + ); + test( + 'missing manifest with a later conflicting publication is never overwritten', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + await files.manifest.delete(); + process = next; + final other = StorePair.candidate( + '99999999-0000-4000-8000-000000000001', + ); + final pending = File('${files.manifest.path}.pending'); + await pending.writeAsString(files.manifestBytes(other)); + expect( + (await service().resume()).disposition, + BackupCommitDisposition.undetermined, + ); + expect(await files.readManifest(), null); + expect(await pending.readAsString(), files.manifestBytes(other)); + expect(await files.database(legacy).exists(), true); + }, + ); + test( + 'same-process service recreation and unknown native identity never count as restart', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + expect( + (await service().resume()).recoveryFollowUp, + RecoveryFollowUp.awaitingNewProcess, + ); + final unknown = RecoveryRestoreService( + files, + gate: gate, + owner: owner, + processIdentity: () async => + throw StateError('channel unavailable'), + cleanupPlatform: () async {}, + repairCutover: () async { + repairs++; + }, + ); + expect((await unknown.resume()).followUpPending, true); + expect(repairs, 0); + expect(await files.database(legacy).exists(), true); + }, + ); + test( + 'duplicate activation owns one real Future across delayed publication', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + final entered = Completer(); + final unblock = Completer(); + var writes = 0; + fault = (step) async { + if (step == 'manifest.written') { + writes++; + entered.complete(); + await unblock.future; + } + }; + final first = recovery.activate(candidate); + await entered.future; + final second = recovery.activate(candidate); + expect(identical(first, second), true); + expect(writes, 1); + expect(gate.isRecoveryPending, true); + unblock.complete(); + await first; + await second; + }, + ); + test( + 'DB cleanup commit followed by record failure keeps writes gated through retry', + () async { + final recovery = service(); + final candidate = await recovery.previewBytes(backup, password); + await recovery.activate(candidate); + process = next; + fault = (step) async { + if (step == 'record.verified.written') { + throw StateError('interrupted'); + } + }; + final pending = await service().resume(); + expect(pending.followUpPending, true); + final db = await openEncryptedPair(files, candidate.record.target); + expect( + (await db.select(db.users).getSingle()).restoreCleanupPending, + false, + ); + await db.close(); + expect(gate.isRecoveryPending, true); + expect(gate.captureWrite, throwsA(isA())); + expect(await files.database(legacy).exists(), true); + final blocked = Completer(); + final entered = Completer(); + fault = (step) async { + if (step == 'record.verified.written') { + entered.complete(); + await blocked.future; + } + }; + final retry = service().resume(); + await entered.future; + expect(gate.isRecoveryPending, true); + expect(gate.captureWrite, throwsA(isA())); + blocked.complete(); + expect((await retry).followUpPending, false); + expect(gate.isAvailable, true); + }, + ); + }, + skip: library == null + ? 'Needs ONTIME_TEST_SQLCIPHER_LIBRARY; plain SQLite is not cipher proof.' + : false, + ); +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: 'test', buildNumber: '1'); +} + +class _HistoryFault extends FlutterSecureStorage { + String mode = ''; + int writes = 0; + bool wrote = false; + Future Function()? beforeHistoryWrite; + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (key == PairKeyStore.historyKey && mode == 'readBack' && wrote) { + throw StateError('unreadable'); + } + return super.read( + key: key, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + } + + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (key == PairKeyStore.historyKey) { + writes++; + await beforeHistoryWrite?.call(); + if (mode == 'before') throw StateError('unavailable'); + } + await super.write( + key: key, + value: value, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + if (key == PairKeyStore.historyKey) { + wrote = true; + if (mode == 'after') throw StateError('lost response'); + } + } +} diff --git a/test/core/database/recovery_time_review_service_test.dart b/test/core/database/recovery_time_review_service_test.dart new file mode 100644 index 00000000..757b4b50 --- /dev/null +++ b/test/core/database/recovery_time_review_service_test.dart @@ -0,0 +1,339 @@ +// Actual authenticated ingestion and host SQLCipher pair files. Platform cleanup +// is an awaited owner callback; these are not native OS delivery tests. +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/open.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/recovery/pair_files.dart'; +import 'package:on_time_front/core/database/recovery/pair_key_store.dart'; +import 'package:on_time_front/core/database/recovery/recovery_restore_service.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import '../../helpers/sodium_test_loader.dart'; +import '../backup/backup_validation_contract_test.dart' show validBackup; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + const password = 'recovery time review password'; + const legacy = StorePair.legacy(); + const firstProcess = '10000000-0000-4000-8000-000000000001'; + const nextProcess = '10000000-0000-4000-8000-000000000002'; + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + late Directory directory; + late PairFiles files; + late LocalDataOperationGate gate; + late AlarmOperationCoordinator alarmOwner; + late BackupProcessingOwner processing; + late DateTime now; + late String process; + var cleanups = 0; + Future Function()? onCleanup; + Future Function(String)? fault; + final selections = []; + RecoveryRestoreService service() => RecoveryRestoreService( + files, + crypto: crypto, + now: () => now, + gate: gate, + owner: alarmOwner, + processingOwner: processing, + processIdentity: () async => process, + ingestionFactory: (b) => BackupIngestionStore.create( + b, + root: Directory('${directory.path}/ingestion'), + ), + cleanupPlatform: () async { + cleanups++; + await onCleanup?.call(); + }, + repairCutover: () async {}, + ); + setUpAll(() { + if (library != null) { + open.overrideForAll(() => DynamicLibrary.open(library)); + } + }); + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({}); + directory = await Directory.systemTemp.createTemp('a10-recovery-time-'); + files = PairFiles( + directory, + PairKeyStore(), + protect: (_) async {}, + checkpoint: (name) async => fault?.call(name), + ); + gate = LocalDataOperationGate(); + alarmOwner = AlarmOperationCoordinator(gate); + processing = BackupProcessingOwner(); + now = DateTime.utc(2030); + process = firstProcess; + cleanups = 0; + onCleanup = null; + fault = null; + selections.clear(); + await files.database(legacy).writeAsString('damaged original preserved'); + }); + tearDown(() async { + fault = null; + onCleanup = null; + for (final value in selections.reversed) { + await value.dispose(); + } + alarmOwner.dispose(); + gate.dispose(); + await directory.delete(recursive: true); + }); + Future encrypted({bool unknown = true}) async { + final input = jsonDecode(jsonEncode(validBackup())) as Map; + input['schedules'][0].addAll({ + 'civilTime': '2031-01-02T09:00:00.123456', + 'timeZoneId': unknown ? 'Removed/Zone' : 'UTC', + 'occurrenceOffsetSeconds': 0, + }); + return crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(input))), + password: password, + ); + } + + Future ready(RecoveryRestoreService port) async { + final input = + await port.reviewBytes(await encrypted(), password) + as BackupTimeReviewInput; + selections.add(input); + final issue = (await input.issues()).items.single; + final summary = input.summary; + final review = await input.review( + BackupTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + civil: issue.civil, + zone: 'UTC', + ), + ); + await input.choose(BackupTimeChoice(review: review, offsetSeconds: 0)); + final selected = await input.revalidate(); + selections.add(selected); + return selected as RecoveryCandidate; + } + + final stale = isA().having( + (e) => e.failure, + 'failure', + DataOperationFailure.stalePreview, + ); + group( + 'SQLCipher recovery authenticated time review', + () { + test( + 'review has no pair record or key and explicit choice produces owned encrypted ready only', + () async { + final port = service(); + final bytes = await encrypted(); + final original = List.of(bytes); + final input = + await port.reviewBytes(bytes, password) as BackupTimeReviewInput; + selections.add(input); + expect(await files.readRecord(), isNull); + expect(await files.readManifest(), isNull); + expect(await files.keys.hasHistory(), isFalse); + expect(cleanups, 0); + expect(gate.generation, 0); + expect( + await files.database(legacy).readAsString(), + 'damaged original preserved', + ); + final issue = (await input.issues()).items.single; + final summary = input.summary; + final choice = await input.review( + BackupTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + civil: issue.civil, + zone: 'UTC', + ), + ); + await input.choose( + BackupTimeChoice(review: choice, offsetSeconds: 0), + ); + final candidate = await input.revalidate() as RecoveryCandidate; + selections.add(candidate); + expect((await files.readRecord())!.stage, PairStage.validated); + expect(await files.readManifest(), isNull); + expect(cleanups, 0); + final db = await port.openPair(candidate.record.target); + try { + final row = await db + .customSelect( + 'SELECT schedule_time, time_zone_id, occurrence_offset_seconds FROM schedules', + ) + .getSingle(); + expect(row.data['schedule_time'], '2031-01-02T09:00:00.123456'); + expect(row.data['time_zone_id'], 'UTC'); + expect(row.data['occurrence_offset_seconds'], 0); + } finally { + await db.close(); + } + expect(bytes, original); + await input.dispose(); + expect(processing.active, isNotNull); + await candidate.dispose(); + expect(processing.active, isNull); + expect(await files.readRecord(), isNull); + }, + ); + for (final rewind in [false, true]) { + test( + 'ready expiry before claim has no generation or cleanup (rewind=$rewind)', + () async { + final port = service(); + final candidate = await ready(port); + now = rewind ? DateTime.utc(2029) : DateTime.utc(2032); + await expectLater(port.activate(candidate), throwsA(stale)); + expect(gate.generation, 0); + expect(cleanups, 0); + expect((await files.readRecord())!.confirmed, isFalse); + expect(await files.readManifest(), isNull); + }, + ); + } + test( + 'expiry during cleanup keeps unconfirmed data and retry cannot activate', + () async { + final port = service(); + final candidate = await ready(port); + final barrier = Completer(); + final entered = Completer(); + onCleanup = () async { + entered.complete(); + await barrier.future; + }; + final pending = port.activate(candidate); + await entered.future; + now = DateTime.utc(2032); + barrier.complete(); + await expectLater( + pending, + throwsA( + stale + .having((e) => e.followUpPending, 'cleanup pending', true) + .having((e) => e.generation, 'generation', 1), + ), + ); + expect((await files.readRecord())!.confirmed, isFalse); + expect(gate.isRecoveryPending, isFalse); + expect(await files.readManifest(), isNull); + expect( + await files.database(legacy).readAsString(), + 'damaged original preserved', + ); + await expectLater(port.retryPreclaimCleanup(0), throwsA(stale)); + onCleanup = () async { + throw StateError('cleanup failed'); + }; + await expectLater( + port.retryPreclaimCleanup(1), + throwsA( + stale.having( + (e) => e.followUpPending, + 'retry still pending', + true, + ), + ), + ); + onCleanup = null; + await port.retryPreclaimCleanup(1); + expect(cleanups, 3); + expect((await files.readRecord())!.confirmed, isFalse); + expect(await files.readManifest(), isNull); + expect(await files.keys.hasHistory(), isFalse); + }, + ); + test( + 'original evidence changes during review prevent candidate construction', + () async { + final port = service(); + final input = + await port.reviewBytes(await encrypted(), password) + as BackupTimeReviewInput; + selections.add(input); + final issue = (await input.issues()).items.single; + final summary = input.summary; + final choice = await input.review( + BackupTimeDraft( + identity: summary.identity, + revision: summary.revision, + rulesIdentity: summary.rulesIdentity, + issueId: issue.id, + civil: issue.civil, + zone: 'UTC', + ), + ); + await input.choose( + BackupTimeChoice(review: choice, offsetSeconds: 0), + ); + await files.database(legacy).writeAsString('externally changed'); + await expectLater(input.revalidate(), throwsA(stale)); + expect(await files.readRecord(), isNull); + expect(cleanups, 0); + }, + ); + test( + 'confirmed intent remains durable when clock advances and new process resumes', + () async { + final port = service(); + final candidate = await ready(port); + fault = (step) async { + if (step == 'record.confirmed.renamed') { + now = DateTime.utc(2032); + throw StateError('lost durable confirmation response'); + } + }; + final receipt = await port.activate(candidate); + expect(receipt.followUpPending, isTrue); + expect((await files.readRecord())!.confirmed, isTrue); + expect(cleanups, 1); + fault = null; + process = nextProcess; + final resumed = await service().resume(); + expect(resumed.disposition, BackupCommitDisposition.committed); + expect(resumed.followUpPending, isFalse); + expect(await files.readManifest(), candidate.record.target); + }, + ); + test('strict preview checks future authority after ready too', () async { + final port = service(); + final candidate = await port.previewBytes( + await encrypted(unknown: false), + password, + ); + selections.add(candidate); + now = DateTime.utc(2032); + await expectLater(port.activate(candidate), throwsA(stale)); + expect(gate.generation, 0); + expect(cleanups, 0); + }); + }, + skip: library == null + ? 'Set ONTIME_TEST_SQLCIPHER_LIBRARY for actual encrypted pair proof' + : false, + ); +} diff --git a/test/core/database/restore_delivery_cleanup_test.dart b/test/core/database/restore_delivery_cleanup_test.dart new file mode 100644 index 00000000..1adb03a7 --- /dev/null +++ b/test/core/database/restore_delivery_cleanup_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/restore_delivery_cleanup.dart'; +import 'package:on_time_front/core/database/local_reset_actions.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_registration_cleanup.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import '../../domain/use-cases/alarm_reconciliation_concurrency_test.dart' + as concurrency; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final nativeGlobalEmpty in [false, true]) { + test( + 'production helper normalizes known ownership with global native=$nativeGlobalEmpty', + () async { + final rig = concurrency.Rig(); + addTearDown(rig.dispose); + rig.repository.schedules = [rig.schedule('same')]; + await rig.reconcile(); + var deliveries = 0, launches = 0; + final actions = DeviceLocalResetActions( + clearNativeDeliveries: () async => nativeGlobalEmpty, + clearDeliveries: () async { + deliveries++; + }, + clearLaunch: () async { + launches++; + }, + deleteFiles: () async => + throw StateError('Restore must not remove DB'), + closeDatabase: () async => + throw StateError('Restore must not close DB'), + ); + final cleanup = AlarmRegistrationCleanup( + rig.registry, + rig.scheduler, + rig.fallback, + rig.operations, + ); + await cleanupRestoreDeliveries( + rig.operations, + actions: actions, + registrationCleanup: cleanup, + ); + expect(rig.registry.records, isEmpty); + final journal = await rig.operations.journal.read(); + expect(journal.ownership, isEmpty); + expect(journal.unknownOwnership, isFalse); + expect(journal.reset, ResetPhase.none); + expect(journal.completed, isEmpty); + expect(deliveries, 1); + expect(launches, 1); + // Cleanup repeats safely and same provider ID can then be registered. + await cleanupRestoreDeliveries( + rig.operations, + actions: actions, + registrationCleanup: cleanup, + ); + expect((await rig.reconcile()).armedScheduleIds, ['same']); + }, + ); + } + test( + 'unknown ownership remains pending without global evidence and resolves with it', + () async { + final rig = concurrency.Rig(); + addTearDown(rig.dispose); + final state = await rig.operations.journal.read(); + state.unknownOwnership = true; + await rig.operations.journal.save(state); + final cleanup = AlarmRegistrationCleanup( + rig.registry, + rig.scheduler, + rig.fallback, + rig.operations, + ); + DeviceLocalResetActions actions(bool empty) => DeviceLocalResetActions( + clearNativeDeliveries: () async => empty, + clearDeliveries: () async {}, + clearLaunch: () async {}, + ); + await expectLater( + cleanupRestoreDeliveries( + rig.operations, + actions: actions(false), + registrationCleanup: cleanup, + ), + throwsA(isA()), + ); + expect((await rig.operations.journal.read()).unknownOwnership, isTrue); + await cleanupRestoreDeliveries( + rig.operations, + actions: actions(true), + registrationCleanup: cleanup, + ); + expect((await rig.operations.journal.read()).unknownOwnership, isFalse); + }, + ); +} diff --git a/test/core/database/restore_runtime_quiescence_test.dart b/test/core/database/restore_runtime_quiescence_test.dart new file mode 100644 index 00000000..cff22c00 --- /dev/null +++ b/test/core/database/restore_runtime_quiescence_test.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import '../../helpers/schedule_deletion_workflow_fixture.dart'; +import '../../helpers/noop_alarm_reconciliation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../domain/use-cases/alarm_reconciliation_concurrency_test.dart' + as concurrency; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'restore waits for actual old delete-runtime Future before same-ID replacement', + () async { + SharedPreferences.setMockInitialValues({ + 'preparation_with_time_same': 'old', + }); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'original', + ), + ); + final timed = _BlockingTimed(); + final repository = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: timed, + ); + addTearDown(repository.dispose); + final rig = concurrency.Rig(); + addTearDown(rig.dispose); + final original = rig.schedule('same'); + await repository.createSchedule(original); + final owner = rig.operations; + final cancel = CancelAllAlarmsUseCase( + rig.registry, + rig.scheduler, + rig.fallback, + operations: owner, + ); + final backup = BackupService( + db, + _Metadata(), + cancel, + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: rig.gate, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, + ); + final bytes = await backup.createEncryptedBackup( + 'portable backup password', + ); + final aggregate = ScheduleAggregateRepositoryImpl( + db, + RecurringScheduleRepositoryImpl(db), + gate: rig.gate, + ); + final sessions = SchedulePreparationSessionUseCase( + repository, + _Preparation(), + timed, + DeletionEarly(), + rig.cancelOne, + NoopAlarmReconciliation(), + operations: owner, + ); + addTearDown(sessions.dispose); + final useCase = DeleteScheduleUseCase( + aggregate, + rig.registry, + rig.scheduler, + rig.fallback, + sessions, + NoopAlarmReconciliation(), + operations: owner, + ); + final deletion = useCase.confirm(await useCase.prepare(original.id)); + final interrupted = expectLater( + deletion, + throwsA(isA()), + ); + await timed.entered.future; + expect(await db.select(db.schedules).get(), isEmpty); + final candidate = await backup.previewEncryptedBackup( + bytes, + 'portable backup password', + ); + var restored = false; + final restoring = backup + .applyRestore(candidate) + .then((_) => restored = true); + await pumpEventQueue(); + expect(restored, isFalse); + expect(await db.select(db.schedules).get(), isEmpty); + timed.release.complete(); + await interrupted; + await restoring; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('preparation_with_time_same', 'new-run'); + await pumpEventQueue(); + expect(prefs.getString('preparation_with_time_same'), 'new-run'); + expect(timed.calls, 1); + expect( + (await db.select(db.schedules).getSingle()).requiresStartConfirmation, + isTrue, + ); + }, + ); +} + +class _BlockingTimed implements TimedPreparationRepository { + final entered = Completer(), release = Completer(); + int calls = 0; + @override + Future getTimedPreparationSnapshot( + String scheduleId, + ) async => null; + @override + Future clearTimedPreparation(String scheduleId) async { + calls++; + entered.complete(); + await release.future; + await (await SharedPreferences.getInstance()).remove( + 'preparation_with_time_$scheduleId', + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Preparation extends Fake implements PreparationRepository {} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1', buildNumber: '1'); +} diff --git a/test/core/database/restore_startup_classification_test.dart b/test/core/database/restore_startup_classification_test.dart new file mode 100644 index 00000000..c20d753b --- /dev/null +++ b/test/core/database/restore_startup_classification_test.dart @@ -0,0 +1,169 @@ +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'unreadable store preserves original cause and cannot be labelled pending restore', + () async { + final original = StateError('synthetic key unavailable'); + final db = AppDatabase.forTesting( + NativeDatabase.memory(setup: (_) => throw original), + ); + addTearDown(db.close); + final gate = LocalDataOperationGate(); + addTearDown(gate.dispose); + var calls = 0; + await expectLater( + RestoreRuntimeIdentity().prepareStartup( + db, + gate, + cleanupPlatform: () async { + calls++; + }, + ), + throwsA( + isA().having( + (e) => e.cause, + 'original cause', + same(original), + ), + ), + ); + expect(calls, 0); + }, + ); + test( + 'known committed marker classifies cleanup failure and preserves the receipt', + () async { + SharedPreferences.setMockInitialValues({}); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'keep', + ), + ); + await db.customStatement( + 'UPDATE users SET restore_cleanup_pending=1,reject_legacy_delivery=1', + ); + final before = await db.select(db.users).getSingle(); + final gate = LocalDataOperationGate(); + addTearDown(gate.dispose); + final original = StateError('OS unknown'); + await expectLater( + RestoreRuntimeIdentity().prepareStartup( + db, + gate, + cleanupPlatform: () async => throw original, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cleanup cause', + same(original), + ), + ), + ); + expect(await db.select(db.users).getSingle(), before); + expect(gate.isRecoveryPending, isTrue); + }, + ); + for (final throwsWrite in [false, true]) { + test( + 'runtime removal ${throwsWrite ? 'exception' : 'false receipt'} leaves durable pending until verified retry', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingStore(throwsWrite); + SharedPreferencesStorePlatform.instance = store; + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'keep', + ), + ); + await db.customStatement( + 'UPDATE users SET restore_cleanup_pending=1,reject_legacy_delivery=1', + ); + final gate = LocalDataOperationGate(); + addTearDown(gate.dispose); + final identity = RestoreRuntimeIdentity(); + var platformCalls = 0; + await expectLater( + identity.cleanup( + db, + gate, + cleanupPlatform: () async { + platformCalls++; + }, + ), + throwsStateError, + ); + expect( + (await db.select(db.users).getSingle()).restoreCleanupPending, + isTrue, + ); + expect(gate.isRecoveryPending, isTrue); + expect(platformCalls, 0); + expect( + (await store.getAll()).containsKey( + 'flutter.early_start_session_same', + ), + isTrue, + ); + store.fail = false; + // A failed legacy remove changes its in-memory cache before persistence; + // reload represents the next retry/restart's authoritative prefs read. + await (await SharedPreferences.getInstance()).reload(); + await identity.cleanup( + db, + gate, + cleanupPlatform: () async { + platformCalls++; + }, + ); + expect( + (await db.select(db.users).getSingle()).restoreCleanupPending, + isFalse, + ); + expect(platformCalls, 1); + expect( + (await store.getAll()).containsKey( + 'flutter.early_start_session_same', + ), + isFalse, + ); + SharedPreferences.setMockInitialValues({}); + }, + ); + } +} + +class _FailingStore extends InMemorySharedPreferencesStore { + _FailingStore(this.throwsWrite) + : super.withData({ + 'flutter.early_start_session_same': 'old', + 'flutter.unrelated': 'keep', + }); + final bool throwsWrite; + bool fail = true; + @override + Future remove(String key) async { + if (fail) { + if (throwsWrite) throw StateError('injected prefs write'); + return false; + } + return super.remove(key); + } +} diff --git a/test/core/database/schedule_aggregate_migration_test.dart b/test/core/database/schedule_aggregate_migration_test.dart index 0bafcfe2..69a7d3f7 100644 --- a/test/core/database/schedule_aggregate_migration_test.dart +++ b/test/core/database/schedule_aggregate_migration_test.dart @@ -6,6 +6,55 @@ import 'package:on_time_front/core/database/database.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; void main() { + test( + 'reconstructed schema3 upgrades only restore-local defaults and preserves existing identities', + () async { + // This is a reconstructed schema3 fixture (current schema minus the three + // additive v4 columns), not evidence from a historical device binary. + final dir = await Directory.systemTemp.createTemp('a09-schema3-'); + addTearDown(() => dir.delete(recursive: true)); + final file = File('${dir.path}/store.sqlite'); + var db = AppDatabase.forTesting(NativeDatabase(file)); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 9), + note: 'schema3', + ), + ); + final before = await db.select(db.users).getSingle(); + await db.close(); + final raw = sqlite3.open(file.path); + raw.execute( + 'ALTER TABLE schedules DROP COLUMN requires_start_confirmation', + ); + raw.execute('ALTER TABLE users DROP COLUMN restore_cleanup_pending'); + raw.execute('ALTER TABLE users DROP COLUMN reject_legacy_delivery'); + raw.execute('PRAGMA user_version=3'); + raw.dispose(); + db = AppDatabase.forTesting(NativeDatabase(file)); + try { + final after = await db.select(db.users).getSingle(); + expect(after.storeIncarnation, before.storeIncarnation); + expect(after.note, 'schema3'); + expect(after.dataRevision, before.dataRevision); + expect(after.restoreCleanupPending, isFalse); + expect(after.rejectLegacyDelivery, isFalse); + expect( + (await db.customSelect('PRAGMA user_version').getSingle()).read( + 'user_version', + ), + 4, + ); + expect( + await db.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + } finally { + await db.close(); + } + }, + ); for (final version in [1, 2]) { test( 'frozen schema $version upgrades atomically and identities survive reopen/upsert', @@ -128,7 +177,7 @@ void main() { ); } test( - 'failed schema3 identity backfill rolls back additive columns and preserves schema2', + 'unknown preexisting trigger is rejected before migration and preserves schema2', () async { final dir = await Directory.systemTemp.createTemp('a08-migration-fault-'); final file = File('${dir.path}/store.sqlite'); diff --git a/test/core/database/schema_contract_test.dart b/test/core/database/schema_contract_test.dart new file mode 100644 index 00000000..58317ebb --- /dev/null +++ b/test/core/database/schema_contract_test.dart @@ -0,0 +1,158 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'package:sqlite3/sqlite3.dart'; + +void main() { + for (final version in DatabaseSchemaContract.legacySupported) { + test('independent schema $version matches its complete contract', () { + final db = sqlite3.openInMemory(); + try { + db.execute( + File( + 'test/fixtures/database/schema_v$version.sql', + ).readAsStringSync(), + ); + DatabaseSchemaContract.validate( + version, + db.select(DatabaseSchemaContract.objectsQuery), + ); + // LIKE sqlite_% would incorrectly hide this legal, unknown table. + db.execute('CREATE TABLE sqlitex_probe (id INTEGER)'); + expect( + () => DatabaseSchemaContract.validate( + version, + db.select(DatabaseSchemaContract.objectsQuery), + ), + throwsA(isA()), + ); + } finally { + db.dispose(); + } + }); + } + test('lexer preserves string whitespace and escaped quote identity', () { + expect( + sqlTokens("CHECK (name = 'a b')"), + isNot(sqlTokens("CHECK(name='a b')")), + ); + expect( + sqlTokens('CHECK ("flag" IN (0, 1))'), + sqlTokens(' check("flag" in(0,1)) '), + ); + expect(sqlTokens("DEFAULT 'don''t , (x)'"), contains("s:don't , (x)")); + expect( + sqlTokens('PRIMARY KEY (first, second)'), + isNot(sqlTokens('PRIMARY KEY (second, first)')), + ); + expect( + sqlTokens('FOREIGN KEY(a,b) REFERENCES t(x,y)'), + isNot(sqlTokens('FOREIGN KEY(b,a) REFERENCES t(x,y)')), + ); + expect(sqlTokens('UNIQUE(a,b)'), isNot(sqlTokens('UNIQUE(b,a)'))); + expect( + () => sqlTokens("DEFAULT 'unterminated"), + throwsA(isA()), + ); + }); + test('quoted fallback strings preserve case as actual SQLite does', () { + final db = sqlite3.openInMemory(); + try { + expect( + db.select('SELECT "notEnded"=\'notEnded\'').single.values.single, + 1, + ); + expect( + db.select('SELECT "NOTENDED"=\'notEnded\'').single.values.single, + 0, + ); + expect( + sqlTokens('DEFAULT "notEnded"'), + isNot(sqlTokens('DEFAULT "NOTENDED"')), + ); + } finally { + db.dispose(); + } + }); + test('quoted NULL is not the NULL keyword in an actual identity trigger', () { + final db = sqlite3.openInMemory(); + try { + db.execute( + File('test/fixtures/database/schema_v4.sql').readAsStringSync(), + ); + final original = + db + .select( + "SELECT sql FROM sqlite_master WHERE name='users_identity'", + ) + .single['sql'] + as String; + db.execute('DROP TRIGGER users_identity'); + db.execute(original.replaceFirst('IS NULL BEGIN', 'IS "NULL" BEGIN')); + db.execute( + "INSERT INTO users(id,spare_time,note) VALUES('local-profile',0,'synthetic')", + ); + expect( + db + .select('SELECT store_incarnation FROM users') + .single['store_incarnation'], + isNull, + ); + expect( + () => DatabaseSchemaContract.validate( + 4, + db.select(DatabaseSchemaContract.objectsQuery), + ), + throwsA(isA()), + ); + } finally { + db.dispose(); + } + }); + for (final change in ['literal', 'check', 'index', 'trigger', 'column']) { + test( + 'current contract unit comparison rejects copied schema descriptors with altered $change', + () { + final db = sqlite3.openInMemory(); + try { + db.execute( + File('test/fixtures/database/schema_v4.sql').readAsStringSync(), + ); + final objects = db + .select(DatabaseSchemaContract.objectsQuery) + .map((r) => Map.from(r)) + .toList(); + if (change == 'index' || change == 'trigger') { + objects.removeWhere((r) => r['type'] == change); + } else { + final row = objects.singleWhere((r) => r['name'] == 'schedules'); + row['sql'] = switch (change) { + 'literal' => (row['sql'] as String).replaceAll( + "'notEnded'", + "'not Ended'", + ), + 'check' => (row['sql'] as String).replaceAll( + 'IN (0, 1)', + 'IN (0, 1, 2)', + ), + _ => (row['sql'] as String).replaceFirst( + 'TEXT NOT NULL', + 'TEXT NULL', + ), + }; + } + expect( + () => DatabaseSchemaContract.validate(4, objects), + throwsA(isA()), + ); + DatabaseSchemaContract.validate( + 4, + db.select(DatabaseSchemaContract.objectsQuery), + ); + } finally { + db.dispose(); + } + }, + ); + } +} diff --git a/test/core/database/start_restore_generation_acceptance_test.dart b/test/core/database/start_restore_generation_acceptance_test.dart new file mode 100644 index 00000000..02a622e5 --- /dev/null +++ b/test/core/database/start_restore_generation_acceptance_test.dart @@ -0,0 +1,420 @@ +// T03 F03/F10/F21 selected start-to-restore generation boundary. +// Actual: file-backed SQLite + independent read-only sqlite3 readers; +// ScheduleRepositoryImpl and SchedulePreparationSessionUseCase; +// both runtime repositories/data sources and their real JSON/prefs protocol; +// BackupService + sodium crypto + restore transaction/runtime cleanup; +// same shared generation gate and operation owner; FileAlarmJournalStore. +// Controlled boundaries: in-memory SharedPreferences PLATFORM store (not native +// disk durability), one false marker-write receipt, one bounded marker latch, +// existing fake OS/registry, no-op reconcile, memory ingestion/staging. +// No process-kill, power-loss, encrypted active SQLite or mobile proof claimed. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/alarm_journal_store.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/data/data_sources/early_start_session_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_with_time_local_data_source.dart'; +import 'package:on_time_front/data/repositories/early_start_session_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/timed_preparation_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; + +import '../../helpers/noop_alarm_reconciliation.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import '../../helpers/sodium_test_loader.dart'; +import '../../domain/use-cases/reconcile_alarms_use_case_test.dart' + show + FakeAlarmRegistryRepository, + FakeAlarmSchedulerService, + FakeFallbackAlarmNotificationService; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'T03 actual start commit partial runtime retry cannot resurrect same-ID restored run', + () async { + const password = 't03 synthetic portable password'; + const id = 'same'; + const markerKey = 'flutter.early_start_session_same'; + const snapshotKey = 'flutter.preparation_with_time_same'; + final now = DateTime.utc(2030, 1, 1, 8); + final startedAt = now.add(const Duration(minutes: 1)); + final directory = await Directory.systemTemp.createTemp( + 't03-start-restore-', + ); + final databaseFile = File('${directory.path}/active.sqlite'); + final db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + // Production ScheduleRepositoryImpl observes/writes this gate, so use it + // consistently for owner, BackupService and the retained UI intent. + final gate = LocalDataOperationGate.shared; + final oldGeneration = gate.generation; + final identity = RestoreRuntimeIdentity.shared; + final priorIdentity = ( + identity.storeIncarnation, + identity.rejectLegacy, + identity.pending, + ); + SharedPreferences.setMockInitialValues({}); + final platform = _RuntimeStore(markerKey); + SharedPreferencesStorePlatform.instance = platform; + final journal = AlarmOwnershipJournal( + FileAlarmJournalStore( + () async => Directory('${directory.path}/journal'), + ), + ); + final owner = AlarmOperationCoordinator(gate, journal: journal); + final registry = FakeAlarmRegistryRepository(); + final native = FakeAlarmSchedulerService(); + final fallback = FakeFallbackAlarmNotificationService(); + final cancelOne = CancelScheduleAlarmUseCase( + registry, + native, + fallback, + operations: owner, + ); + final cancelAll = CancelAllAlarmsUseCase( + registry, + native, + fallback, + operations: owner, + ); + final timed = TimedPreparationRepositoryImpl( + localDataSource: PreparationWithTimeLocalDataSourceImpl(), + ); + final early = EarlyStartSessionRepositoryImpl( + localDataSource: EarlyStartSessionLocalDataSourceImpl(), + ); + final schedules = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: timed, + now: () => now, + ); + final reconcile = NoopAlarmReconciliation(); + final sessions = SchedulePreparationSessionUseCase( + schedules, + _UnusedPreparation(), + timed, + early, + cancelOne, + reconcile, + operations: owner, + ); + final backup = BackupService( + db, + _Metadata(), + cancelAll, + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + operationGate: gate, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: identity, + cleanupPlatform: noPlatformRestoreCleanup, + now: () => now, + ); + BackupRestoreCandidate? candidate; + Future? oldRetryAssertion; + Future? restoring; + final generationClaimed = Completer(); + void changed() { + if (gate.generation != oldGeneration && + !generationClaimed.isCompleted) { + generationClaimed.complete(); + } + } + + gate.addListener(changed); + try { + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'synthetic profile sentinel', + isOnboardingCompleted: true, + ), + ); + await identity.load(db); + final oldIncarnation = identity.storeIncarnation; + await schedules.createSchedule( + ScheduleEntity( + id: id, + place: const PlaceEntity( + id: 'place-same', + placeName: 'private-place-sentinel', + ), + scheduleName: 'private-title-sentinel', + scheduleTime: DateTime.utc(2030, 1, 1, 12), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: 'private-note-sentinel', + ), + ); + const preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-same', + preparationName: 'private-preparation-sentinel', + preparationTime: Duration(minutes: 5), + ), + ], + ); + await db.preparationScheduleDao.createPreparationSchedule( + preparation, + id, + ); + final originalSchedule = await schedules.getScheduleById(id); + final original = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + originalSchedule, + PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve( + originalSchedule, + nowUtc: now, + ), + ); + final beforeStart = _readLogicalSnapshot(databaseFile); + final beforeRevision = + beforeStart['users']!.single['data_revision'] as int; + bool oldIntentIsCurrent() => gate.generation == oldGeneration; + + // Failure is injected at the existing platform storage boundary AFTER + // the real durable start commit. Snapshot serialization remains real. + platform.failNextMarker = true; + final receipt = await sessions.startEarlySession( + original, + startedAt: startedAt, + isCurrent: oldIntentIsCurrent, + ); + expect(receipt.hasPendingRecovery, isTrue); + expect(receipt.startedAt.toUtc(), startedAt); + expect(receipt.durableStartedAt?.toUtc(), startedAt); + final committed = _readLogicalSnapshot(databaseFile); + expect(committed['users']!.single['data_revision'], beforeRevision + 1); + expect(committed['schedules']!.single['is_started'], 1); + expect(committed['schedules']!.single['preparation_frozen'], 1); + final firstStoredInstant = committed['schedules']!.single['started_at']; + expect(firstStoredInstant, isNotNull); + final rawRuntime = await platform.getAll(); + expect(rawRuntime.containsKey(markerKey), isFalse); + expect(rawRuntime[snapshotKey], isA()); + final snapshotPayload = rawRuntime[snapshotKey] as String; + for (final sentinel in [ + 'private-title-sentinel', + 'private-place-sentinel', + 'private-note-sentinel', + 'private-preparation-sentinel', + ]) { + expect(snapshotPayload, isNot(contains(sentinel))); + } + // Legacy SharedPreferences updates its cache before a false platform + // receipt. Reload so the assertion observes the actual platform store. + await (await SharedPreferences.getInstance()).reload(); + expect(await early.getSession(id), isNull); + expect( + (await timed.getTimedPreparationSnapshot(id))!.startedAt!.toUtc(), + startedAt, + ); + + // Export after the durable partial start, then restore the same ID and + // identical timing/preparation shape. Backup excludes active runtime. + final bytes = await backup.createEncryptedBackup(password); + candidate = await backup.previewEncryptedBackup(bytes, password); + final beforeRace = _readLogicalSnapshot(databaseFile); + platform.holdNextMarker = true; + final oldRetry = sessions.startEarlySession( + original, + startedAt: startedAt, + isCurrent: oldIntentIsCurrent, + ); + oldRetryAssertion = expectLater( + oldRetry, + throwsA(isA()), + ); + await platform.markerEntered.future.timeout( + const Duration(seconds: 10), + ); + var restoreCompleted = false; + restoring = backup.applyRestoreWithReceipt(candidate).then((value) { + restoreCompleted = true; + return value; + }); + await generationClaimed.future.timeout(const Duration(seconds: 10)); + expect(gate.generation, oldGeneration + 1); + expect(gate.isReplacingData, isTrue); + expect(restoreCompleted, isFalse); + // A separately opened SQL reader proves replacement has not overtaken + // the still-owned runtime write. No timing/sleep-based success oracle. + expect(_readLogicalSnapshot(databaseFile), beforeRace); + expect(platform.markerRelease.isCompleted, isFalse); + platform.markerRelease.complete(); + await oldRetryAssertion.timeout(const Duration(seconds: 10)); + expect( + await restoring.timeout(const Duration(seconds: 10)), + oldGeneration + 1, + ); + expect(gate.isAvailable, isTrue); + final restored = _readLogicalSnapshot(databaseFile); + expect( + restored['users']!.single['store_incarnation'], + isNot(oldIncarnation), + ); + expect(restored['users']!.single['restore_cleanup_pending'], 0); + final restoredSchedule = restored['schedules']!.single; + expect(restoredSchedule['id'], id); + expect(restoredSchedule['is_started'], 0); + expect(restoredSchedule['requires_start_confirmation'], 1); + expect(restoredSchedule['started_at'], firstStoredInstant); + expect(restoredSchedule['preparation_frozen'], 1); + final currentSchedule = await schedules.getScheduleById(id); + final currentPreparation = await db.preparationScheduleDao + .getPreparationSchedulesByScheduleId(id); + final sameContent = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + currentSchedule, + PreparationWithTimeEntity.fromPreparation(currentPreparation), + timeResolution: ScheduleTimeResolver.resolve( + currentSchedule, + nowUtc: now, + ), + ); + expect(sameContent.cacheFingerprint, original.cacheFingerprint); + expect(await platform.getAll(), isEmpty); + expect(await early.getSession(id), isNull); + expect(await timed.getTimedPreparationSnapshot(id), isNull); + + // Re-dispatch of the OLD captured intent is distinct from a new user + // confirmation. The production Bloc supplies this generation validity + // callback; this test uses its public use-case contract directly. + final runtimeWrites = platform.setAttempts; + final reconcileCalls = reconcile.callCount; + await expectLater( + sessions.startEarlySession( + original, + startedAt: startedAt, + isCurrent: oldIntentIsCurrent, + ), + throwsA(isA()), + ); + expect(_readLogicalSnapshot(databaseFile), restored); + expect(await platform.getAll(), isEmpty); + expect(platform.setAttempts, runtimeWrites); + expect(reconcile.callCount, reconcileCalls); + expect(native.scheduledNative, isEmpty); + expect(fallback.scheduledFallback, isEmpty); + } finally { + if (!platform.markerRelease.isCompleted) { + platform.markerRelease.complete(); + } + // Always settle the owned futures before disposing repositories/files. + try { + await oldRetryAssertion; + await restoring; + } finally { + await candidate?.dispose(); + sessions.dispose(); + await schedules.dispose(); + owner.dispose(); + gate.removeListener(changed); + await db.close(); + identity.storeIncarnation = priorIdentity.$1; + identity.rejectLegacy = priorIdentity.$2; + identity.pending = priorIdentity.$3; + SharedPreferences.setMockInitialValues({}); + if (gate.isRecoveryPending) gate.setRecoveryPending(false); + await directory.delete(recursive: true); + } + } + }, + ); +} + +// Test-only platform adapter. Product runtime repositories/serialization are +// unchanged. This deliberately claims only a memory-backed host prefs store. +class _RuntimeStore extends InMemorySharedPreferencesStore { + _RuntimeStore(this.markerKey) : super.empty(); + final String markerKey; + bool failNextMarker = false; + bool holdNextMarker = false; + int setAttempts = 0; + final markerEntered = Completer(); + final markerRelease = Completer(); + + @override + Future setValue(String valueType, String key, Object value) async { + setAttempts++; + if (key == markerKey && failNextMarker) { + failNextMarker = false; + return false; + } + if (key == markerKey && holdNextMarker) { + holdNextMarker = false; + markerEntered.complete(); + await markerRelease.future; + } + return super.setValue(valueType, key, value); + } +} + +Map>> _readLogicalSnapshot(File file) { + final reader = sqlite.sqlite3.open(file.path, mode: sqlite.OpenMode.readOnly); + try { + final names = reader.select( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ); + return { + for (final table in names) + table['name'] as String: _readRows(reader, table['name'] as String), + }; + } finally { + reader.dispose(); + } +} + +List> _readRows(sqlite.Database reader, String table) { + final escaped = table.replaceAll('"', '""'); + final rows = reader + .select('SELECT * FROM "$escaped"') + .map((row) => Map.from(row)) + .toList(); + rows.sort((a, b) => jsonEncode(a).compareTo(jsonEncode(b))); + return rows; +} + +class _UnusedPreparation extends Fake implements PreparationRepository {} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1', buildNumber: '1'); +} diff --git a/test/core/services/alarm_scheduler_service_test.dart b/test/core/services/alarm_scheduler_service_test.dart index c7ba58e5..da2363b2 100644 --- a/test/core/services/alarm_scheduler_service_test.dart +++ b/test/core/services/alarm_scheduler_service_test.dart @@ -42,7 +42,7 @@ void main() { { 'type': 'schedule_alarm', 'scheduleId': 'schedule-1', - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'alarm', }, ]); @@ -63,7 +63,7 @@ void main() { { 'type': 'schedule_alarm', 'scheduleId': 'schedule-2', - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'alarm', }, ]); @@ -212,7 +212,7 @@ void main() { expect(nativeArguments!['payload'], { 'type': 'schedule_alarm', 'scheduleId': 'schedule-1', - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'alarm', }); }); diff --git a/test/core/services/civil_legacy_runtime_migration_test.dart b/test/core/services/civil_legacy_runtime_migration_test.dart new file mode 100644 index 00000000..7afbb6eb --- /dev/null +++ b/test/core/services/civil_legacy_runtime_migration_test.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/services/runtime_privacy_migration.dart'; +import 'package:on_time_front/data/data_sources/alarm_registry_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_with_time_local_data_source.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test( + 'unresolved rows do not prevent a normal legacy run from migrating', + () async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(database.close); + final examples = [ + ( + id: 'normal', + civil: DateTime.utc(2026, 9, 23, 10), + zone: 'UTC', + offset: 0, + ), + ( + id: 'unknown', + civil: DateTime.utc(2026, 9, 23, 10), + zone: 'Missing/Zone', + offset: 0, + ), + ( + id: 'gap', + civil: DateTime.utc(2026, 3, 8, 2, 30), + zone: 'America/New_York', + offset: null, + ), + ( + id: 'ambiguous', + civil: DateTime.utc(2026, 11, 1, 1, 30), + zone: 'America/New_York', + offset: null, + ), + ( + id: 'past-null', + civil: DateTime.utc(2025, 9, 23, 10), + zone: 'UTC', + offset: null, + ), + ]; + final prefs = await SharedPreferences.getInstance(); + for (final example in examples) { + await database.scheduleDao.createSchedule( + ScheduleEntity( + id: example.id, + place: const PlaceEntity(id: 'place', placeName: 'Place'), + scheduleName: example.id, + scheduleNote: '', + scheduleTime: example.civil, + timeZoneId: example.zone, + occurrenceOffsetSeconds: example.offset, + moveTime: const Duration(minutes: 10), + scheduleSpareTime: const Duration(minutes: 3), + isChanged: false, + isStarted: false, + ).toScheduleWithPlaceRow(), + ); + await prefs.setString( + 'preparation_with_time_${example.id}', + jsonEncode({ + 'savedAt': DateTime.utc(2026, 1, 1).millisecondsSinceEpoch, + 'startedAt': null, + 'scheduleFingerprint': + '2026-09-23T10:00:00.000|UTC|0|600000|180000|step:Prepare:300000:|', + 'actionEvents': [], + 'steps': [ + { + 'id': 'step', + 'name': 'Prepare', + 'time': 300000, + 'nextId': null, + 'elapsed': 120000, + 'isDone': false, + }, + ], + }), + ); + await prefs.setString('early_start_session_${example.id}', '{}'); + } + final before = + (await database + .customSelect('SELECT * FROM schedules ORDER BY id') + .get()) + .map((row) => row.data) + .toList(); + final snapshots = PreparationWithTimeLocalDataSourceImpl(); + await RuntimePrivacyMigration( + _DatabaseSchedules(database), + _Preparations(), + snapshots, + AlarmRegistryLocalDataSourceImpl(), + cleanPlatform: () async {}, + now: () => DateTime.utc(2026, 1, 1), + ).run(); + final normal = (await snapshots.loadPreparation('normal'))!; + expect(normal.requiresConfirmation, isFalse); + expect( + normal.preparation.preparationStepList.single.elapsedTime, + const Duration(minutes: 2), + ); + for (final example in examples.skip(1)) { + expect( + prefs.containsKey('preparation_with_time_${example.id}'), + isFalse, + ); + expect(prefs.containsKey('early_start_session_${example.id}'), isFalse); + } + final after = + (await database + .customSelect('SELECT * FROM schedules ORDER BY id') + .get()) + .map((row) => row.data) + .toList(); + expect(after, before); + }, + ); + + test('snapshot storage failure is not classified as a time review', () async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(database.close); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('preparation_with_time_unknown', '{}'); + await expectLater( + RuntimePrivacyMigration( + _DatabaseSchedules(database), + _Preparations(), + _UnreadableSnapshots(), + AlarmRegistryLocalDataSourceImpl(), + cleanPlatform: () async {}, + ).run(), + throwsA( + isA().having( + (error) => error.message, + 'original failure', + 'storage unavailable', + ), + ), + ); + }); + + for (final changedCivil in [false, true]) { + test( + 'old no-Z progress through SQLite migration; changed=$changedCivil', + () async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(database.close); + await database.scheduleDao.createSchedule( + ScheduleEntity( + id: 'schedule', + place: const PlaceEntity(id: 'place', placeName: 'Place'), + scheduleName: 'Schedule', + scheduleNote: '', + scheduleTime: DateTime.utc(2026, 9, 23, changedCivil ? 11 : 10), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: const Duration(minutes: 10), + scheduleSpareTime: const Duration(minutes: 3), + isChanged: false, + isStarted: true, + preparationFrozen: true, + startedAt: DateTime.utc(2026, 9, 23, 9, 42), + ).toScheduleWithPlaceRow(), + ); + final row = await database.scheduleDao.getScheduleById('schedule'); + expect(row.schedule.scheduleTime.isUtc, isTrue); + final prefs = await SharedPreferences.getInstance(); + // Exact bytes of the pre-carrier converter's local civil serializer. + // Deliberately not produced by the new fingerprint getter. + const oldFingerprint = + '2026-09-23T10:00:00.000|UTC|0|600000|180000|step:Prepare:300000:|'; + await prefs.setString( + 'preparation_with_time_schedule', + jsonEncode({ + 'savedAt': DateTime.utc(2026, 9, 23, 9, 44).millisecondsSinceEpoch, + 'startedAt': DateTime.utc( + 2026, + 9, + 23, + 9, + 42, + ).millisecondsSinceEpoch, + 'scheduleFingerprint': oldFingerprint, + 'actionEvents': [], + 'steps': [ + { + 'id': 'step', + 'name': 'Prepare', + 'time': 300000, + 'nextId': null, + 'elapsed': 120000, + 'isDone': false, + }, + ], + }), + ); + final snapshots = PreparationWithTimeLocalDataSourceImpl(); + await RuntimePrivacyMigration( + _DatabaseSchedules(database), + _Preparations(), + snapshots, + AlarmRegistryLocalDataSourceImpl(), + cleanPlatform: () async {}, + ).run(); + final migrated = (await snapshots.loadPreparation('schedule'))!; + expect( + ScheduleWithPreparationEntity.isCurrentIdentity( + migrated.scheduleFingerprint, + ), + isTrue, + ); + expect(migrated.requiresConfirmation, changedCivil); + expect( + migrated.preparation.preparationStepList.single.elapsedTime, + Duration(minutes: changedCivil ? 0 : 2), + ); + if (!changedCivil) { + expect(migrated.startedAt!.toUtc(), DateTime.utc(2026, 9, 23, 9, 42)); + } + final raw = prefs.getString('preparation_with_time_schedule')!; + expect(raw, isNot(contains('Prepare'))); + expect(raw, isNot(contains(oldFingerprint))); + expect( + (await database.scheduleDao.getScheduleById( + 'schedule', + )).schedule.scheduleTime, + row.schedule.scheduleTime, + ); + }, + ); + } +} + +class _DatabaseSchedules implements ScheduleRepository { + _DatabaseSchedules(this.database); + final AppDatabase database; + @override + Future getScheduleById(String id) async => + (await database.scheduleDao.getScheduleById(id)).toScheduleEntity(); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Preparations implements PreparationLocalDataSource { + @override + Future getPreparationByScheduleId(String id) async => + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _UnreadableSnapshots extends PreparationWithTimeLocalDataSourceImpl { + @override + Future loadPreparation( + String scheduleId, + ) async => throw StateError('storage unavailable'); +} diff --git a/test/core/services/detailed_notification_preference_acceptance_test.dart b/test/core/services/detailed_notification_preference_acceptance_test.dart new file mode 100644 index 00000000..a8a26c5d --- /dev/null +++ b/test/core/services/detailed_notification_preference_acceptance_test.dart @@ -0,0 +1,264 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; + +// Real SQLite and transaction rollback; not a mobile encryption receipt. +class _AfterPreferenceUpdate extends QueryInterceptor { + bool armed = false; + bool holdRead = false; + final readEntered = Completer(), readRelease = Completer(); + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await executor.runSelect(statement, args); + if (holdRead && statement.contains('FROM "users"')) { + holdRead = false; + readEntered.complete(); + await readRelease.future; + } + return result; + } + + final entered = Completer(); + final release = Completer(); + + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await executor.runUpdate(statement, args); + if (armed && statement.contains('detailed_notification_content')) { + armed = false; + entered.complete(); + await release.future; + } + return result; + } +} + +void main() { + late AppDatabase db; + late LocalDataOperationGate gate; + late DetailedNotificationPreferenceService service; + late _AfterPreferenceUpdate barrier; + + setUp(() async { + barrier = _AfterPreferenceUpdate(); + db = AppDatabase.forTesting(NativeDatabase.memory().interceptWith(barrier)); + gate = LocalDataOperationGate(); + service = DetailedNotificationPreferenceService(db, gate: gate); + await db.customStatement( + "INSERT INTO users(id,spare_time,note,data_revision,alarms_enabled," + "detailed_notification_content) VALUES('local-profile',7,'keep',9,0,1)", + ); + }); + tearDown(() async { + if (!barrier.release.isCompleted) barrier.release.complete(); + if (!barrier.readRelease.isCompleted) barrier.readRelease.complete(); + await db.close(); + gate.dispose(); + }); + + Future> durable() async => + (await db.customSelect('SELECT * FROM users').getSingle()).data; + + test('read and same-value write preserve actual row and revision', () async { + final before = await durable(); + final read = await service.read(expectedGeneration: 0); + expect(read.detailedEnabled, isTrue); + expect(read.scheduleNotificationsEnabled, isFalse); + expect(read.generation, 0); + final same = await service.write(true, expectedGeneration: 0); + expect(same.detailedEnabled, isTrue); + expect(await durable(), before); + }); + + test( + 'OFF changes only detailed preference and one durable revision', + () async { + final before = await durable(); + final earliest = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final saved = await service.write(false, expectedGeneration: 0); + expect(saved.detailedEnabled, isFalse); + expect(saved.scheduleNotificationsEnabled, isFalse); + final after = await durable(); + expect(after, { + ...before, + 'detailed_notification_content': 0, + 'data_revision': 10, + 'first_durable_data_at': after['first_durable_data_at'], + 'last_durable_data_at': after['last_durable_data_at'], + }); + final latest = DateTime.now().millisecondsSinceEpoch ~/ 1000; + expect( + after['first_durable_data_at'], + inInclusiveRange(earliest, latest), + ); + expect(after['last_durable_data_at'], after['first_durable_data_at']); + await service.write(false, expectedGeneration: 0); + expect(await durable(), after); + expect( + (await service.read(expectedGeneration: 0)).detailedEnabled, + isFalse, + ); + }, + ); + + test( + 'missing profile cannot be read or written as confirmed private', + () async { + await db.customStatement('DELETE FROM users'); + await expectLater( + service.read(expectedGeneration: 0), + throwsA(isA()), + ); + await expectLater( + Future.sync(() => service.write(false, expectedGeneration: 0)), + throwsA(anything), + ); + expect(await db.select(db.users).get(), isEmpty); + }, + ); + + test( + 'old queued intent rejects after replacement even when gate is free', + () async { + final before = await durable(); + final acceptedGeneration = gate.generation; + await gate.run(() async {}, replacesData: true); + await expectLater( + Future.sync( + () => service.write(false, expectedGeneration: acceptedGeneration), + ), + throwsA(isA()), + ); + expect(await durable(), before); + expect( + (await service.read( + expectedGeneration: gate.generation, + )).detailedEnabled, + isTrue, + ); + }, + ); + + test( + 'replacement during actual SQL update rolls back bool and revision', + () async { + final before = await durable(); + barrier.armed = true; + final writing = service.write(false, expectedGeneration: 0); + final rejected = expectLater( + writing, + throwsA(isA()), + ); + try { + await Future.any([ + barrier.entered.future, + writing.then((_) { + fail('write finished before SQL barrier'); + }), + ]); + await gate.run(() async {}, replacesData: true); + } finally { + if (!barrier.release.isCompleted) barrier.release.complete(); + await rejected; + } + expect(await durable(), before); + }, + ); + + test( + 'a read that already fetched ON cannot publish it into a replacement generation', + () async { + barrier.holdRead = true; + final reading = service.read(expectedGeneration: 0); + final rejected = expectLater( + reading, + throwsA(isA()), + ); + try { + await barrier.readEntered.future.timeout(const Duration(seconds: 10)); + await gate.run(() async {}, replacesData: true); + } finally { + if (!barrier.readRelease.isCompleted) barrier.readRelease.complete(); + await rejected; + } + }, + ); + + test( + 'a write waiting for the actual DB transaction lock keeps its original generation', + () async { + final before = await durable(); + final entered = Completer(), release = Completer(); + final blocker = db.transaction(() async { + entered.complete(); + await release.future; + }); + await entered.future; + final waiting = service.write(false, expectedGeneration: 0); + final rejected = expectLater( + waiting, + throwsA(isA()), + ); + try { + await gate.run(() async {}, replacesData: true); + } finally { + release.complete(); + await blocker; + await rejected; + } + expect(await durable(), before); + }, + ); + + test( + 'ordinary OFF persists while export owns a nonreplacement gate', + () async { + await gate.run(() async { + expect(gate.isAvailable, isFalse); + final saved = await service.write(false, expectedGeneration: 0); + expect(saved.detailedEnabled, isFalse); + expect((await durable())['data_revision'], 10); + }); + expect( + (await service.read(expectedGeneration: 0)).detailedEnabled, + isFalse, + ); + }, + ); + + test( + 'cleanup-pending and invalidated installations expose no authority', + () async { + final before = await durable(); + gate.setRecoveryPending(true); + await expectLater( + service.read(expectedGeneration: 0), + throwsA(isA()), + ); + await expectLater( + Future.sync(() => service.write(false, expectedGeneration: 0)), + throwsA(isA()), + ); + gate.setRecoveryPending(false); + gate.invalidate(); + await expectLater( + service.read(expectedGeneration: gate.generation), + throwsA(isA()), + ); + expect(await durable(), before); + }, + ); +} diff --git a/test/core/services/notification_routing_test.dart b/test/core/services/notification_routing_test.dart index f58247d6..6f36100c 100644 --- a/test/core/services/notification_routing_test.dart +++ b/test/core/services/notification_routing_test.dart @@ -69,7 +69,7 @@ void main() { expect(target.extra, { 'type': 'schedule_notification', 'scheduleId': 'schedule-1', - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'notification', }); }, @@ -148,7 +148,7 @@ void main() { extra: { 'type': 'schedule_notification', 'scheduleId': 'schedule-2', - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'notification', }, ), diff --git a/test/core/services/notification_tap_router_test.dart b/test/core/services/notification_tap_router_test.dart index 69b22f47..c92b29c5 100644 --- a/test/core/services/notification_tap_router_test.dart +++ b/test/core/services/notification_tap_router_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'dart:async'; import 'dart:convert'; @@ -18,7 +19,8 @@ ScheduleWithPreparationEntity tapSchedule(String id) => id: id, place: const PlaceEntity(id: 'place', placeName: 'Office'), scheduleName: 'Schedule $id', - scheduleTime: _scheduleTime, + scheduleTime: _scheduleTime.toUtc(), + occurrenceOffsetSeconds: 0, moveTime: Duration.zero, isChanged: false, isStarted: false, @@ -64,6 +66,43 @@ void main() { gate.dispose(); }); + test( + 'cold same-ID old and legacy payloads are rejected without attaching current identity', + () async { + final authority = RestoreRuntimeIdentity.shared; + final oldIdentity = authority.storeIncarnation, + oldReject = authority.rejectLegacy, + oldPending = authority.pending; + addTearDown(() { + authority.storeIncarnation = oldIdentity; + authority.rejectLegacy = oldReject; + authority.pending = oldPending; + }); + authority.storeIncarnation = '22222222-2222-2222-2222-222222222222'; + authority.rejectLegacy = true; + authority.pending = false; + ready = true; + router.routeLocalNotificationTap(tapPayload('same')); + router.routeNativeNotificationTap({ + 'type': 'schedule_alarm', + 'scheduleId': 'same', + 'storeIncarnation': '11111111-1111-1111-1111-111111111111', + }); + await pumpEventQueue(); + expect(resolved, isEmpty); + expect(navigation.data, isEmpty); + router.routeLocalNotificationTap( + jsonEncode({ + 'type': 'schedule_notification', + 'scheduleId': 'same', + 'storeIncarnation': authority.storeIncarnation, + }), + ); + await pumpEventQueue(); + expect(resolved, ['same']); + expect(navigation.data.single.schedule.id, 'same'); + }, + ); test( 'last valid tap survives delayed readiness and malformed input', () async { diff --git a/test/core/services/scheduled_notification_delivery_test.dart b/test/core/services/scheduled_notification_delivery_test.dart index 602c9665..f888e18e 100644 --- a/test/core/services/scheduled_notification_delivery_test.dart +++ b/test/core/services/scheduled_notification_delivery_test.dart @@ -86,7 +86,7 @@ void main() { expect(jsonDecode(fallback['payload'] as String), { 'type': 'schedule_notification', 'scheduleId': original.scheduleId, - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'notification', }); expect(native['payload'], jsonDecode(fallback['payload'] as String)); diff --git a/test/core/startup/startup_dependency_scope_test.dart b/test/core/startup/startup_dependency_scope_test.dart new file mode 100644 index 00000000..05471982 --- /dev/null +++ b/test/core/startup/startup_dependency_scope_test.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; + +class _Resource { + _Resource(this.label, this.log, {this.failure = false, this.pending}) { + StartupDependencyScope.own(this, close); + } + final String label; + final List log; + bool failure; + Completer? pending; + bool closed = false; + Future close() async { + log.add(label); + await pending?.future; + if (failure) throw StateError('private injected cleanup failure'); + closed = true; + } + + Future dispose() => StartupDependencyScope.release(this, close); +} + +void main() { + test( + 'middle disposal fails but every LIFO owner runs; only failed owner retries', + () async { + final getIt = GetIt.asNewInstance(); + final owner = StartupDependencyScope(getIt); + final log = []; + late _Resource middle; + owner.configure(() { + for (final label in ['first', 'middle', 'last']) { + final r = _Resource(label, log, failure: label == 'middle'); + if (label == 'middle') middle = r; + getIt.registerSingleton<_Resource>( + r, + instanceName: label, + dispose: (r) => r.dispose(), + ); + } + }); + await expectLater( + owner.cleanup(), + throwsA(isA()), + ); + expect(log, ['last', 'middle', 'first']); + expect(getIt.hasScope(owner.name), false); + expect(owner.cleanupComplete, false); + middle.failure = false; + await owner.cleanup(); + expect(log, ['last', 'middle', 'first', 'middle']); + expect(owner.cleanupComplete, true); + }, + ); + + test( + 'lazy resolution outside registration Zone still belongs to live GetIt scope', + () async { + final owner = StartupDependencyScope(GetIt.I); + final log = []; + owner.configure(() { + GetIt.I.registerLazySingleton<_Resource>( + () => _Resource('lazy', log), + dispose: (r) => r.dispose(), + ); + }); + final resource = GetIt.I<_Resource>(); + resource.failure = true; + await expectLater( + owner.cleanup(), + throwsA(isA()), + ); + expect(log, ['lazy']); + resource.failure = false; + await owner.cleanup(); + expect(log, ['lazy', 'lazy']); + }, + ); + + test('constructor resource is cleaned even when never registered', () async { + final getIt = GetIt.asNewInstance(); + final owner = StartupDependencyScope(getIt); + final log = []; + expect( + () => owner.configure(() { + _Resource('unregistered', log); + throw StateError('registration failed'); + }), + throwsStateError, + ); + await owner.cleanup(); + expect(log, ['unregistered']); + expect(owner.cleanupComplete, true); + }); + + test('in-flight disposal is observed once across repeated cleanup', () async { + final owner = StartupDependencyScope(GetIt.asNewInstance()); + final log = []; + final pending = Completer(); + owner.track(() => _Resource('pending', log, pending: pending)); + final a = owner.cleanup(); + final b = owner.cleanup(); + expect(identical(a, b), true); + await Future.delayed(Duration.zero); + expect(log, ['pending']); + expect(owner.cleanupComplete, false); + pending.complete(); + await a; + expect(owner.cleanupComplete, true); + }); +} diff --git a/test/core/startup/startup_product_graph_test.dart b/test/core/startup/startup_product_graph_test.dart new file mode 100644 index 00000000..e60f8466 --- /dev/null +++ b/test/core/startup/startup_product_graph_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'production registrations close real repository watches and Drift before a replacement graph', + () async { + final container = GetIt.I; + expect(container.currentScopeName, 'baseScope'); + for (var attempt = 0; attempt < 2; attempt++) { + final owner = StartupDependencyScope(container); + final database = AppDatabase.forTesting(NativeDatabase.memory()); + final oldSkip = container.skipDoubleRegistration; + var closes = 0; + void register() { + StartupDependencyScope.own(database, () async { + await database.close(); + closes++; + }); + container.registerSingleton( + database, + dispose: (value) => + StartupDependencyScope.release(value, value.close), + ); + // Only the database port is substituted. Every generated registration, + // repository, use case and startup singleton executes production code. + // The skipped product LazyDatabase is owned too and is never opened. + container.skipDoubleRegistration = true; + try { + configureDependencies(); + } finally { + container.skipDoubleRegistration = oldSkip; + } + if (attempt == 0) throw StateError('injected registration failure'); + } + + if (attempt == 0) { + expect(() => owner.configure(register), throwsStateError); + } else { + owner.configure(register); + } + final user = container(); + final preparation = container(); + final schedule = container(); + final bloc = container(); + final done = List.generate(3, (_) => Completer()); + var emissions = 0; + final subscriptions = >[ + user.userStream.listen((_) => emissions++, onDone: done[0].complete), + preparation.preparationStream.listen( + (_) => emissions++, + onDone: done[1].complete, + ), + schedule.scheduleStream.listen( + (_) => emissions++, + onDone: done[2].complete, + ), + ]; + await user.getUser(); + await database + .customSelect('SELECT count(*) AS n FROM users') + .getSingle(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(emissions, greaterThanOrEqualTo(3)); + // The first graph actually threw after registration; both owners close + // their real graph before a subsequent attempt can start. + await owner.cleanup(); + await Future.wait(done.map((entry) => entry.future)); + expect(closes, 1); + expect(bloc.isClosed, true); + expect(owner.cleanupComplete, true); + expect(container.isRegistered(), false); + await expectLater( + database.customSelect('SELECT 1').get(), + throwsStateError, + ); + final afterCleanup = emissions; + LocalDataOperationGate.shared.setRecoveryPending(true); + LocalDataOperationGate.shared.setRecoveryPending(false); + await Future.delayed(const Duration(milliseconds: 20)); + expect(emissions, afterCleanup); + for (final subscription in subscriptions) { + await subscription.cancel(); + } + } + expect(container.currentScopeName, 'baseScope'); + }, + ); +} diff --git a/test/core/startup/subscription_cleanup_test.dart b/test/core/startup/subscription_cleanup_test.dart new file mode 100644 index 00000000..3d31f626 --- /dev/null +++ b/test/core/startup/subscription_cleanup_test.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/startup/subscription_cleanup.dart'; + +void main() { + test( + 'retired real subscription cancellation remains owned until completion', + () async { + final release = Completer(); + var cancellations = 0; + final stream = StreamController( + onCancel: () { + cancellations++; + return release.future; + }, + ); + final owner = SubscriptionCleanup(); + owner.retire(stream.stream.listen((_) {})); + var closed = false; + final closing = owner.close().then((_) => closed = true); + await pumpEventQueue(); + expect(closed, false); + expect(cancellations, 1); + release.complete(); + await closing; + await owner.close(); + expect(cancellations, 1); + await stream.close(); + }, + ); + + test( + 'awaited retirement and owner close observe the same cancel Future', + () async { + final release = Completer(); + var cancellations = 0; + final stream = StreamController( + onCancel: () { + cancellations++; + return release.future; + }, + ); + final owner = SubscriptionCleanup(); + final retired = owner.cancelAndWait(stream.stream.listen((_) {})); + final closing = owner.close(); + await pumpEventQueue(); + expect(cancellations, 1); + release.complete(); + await Future.wait([retired, closing]); + await stream.close(); + }, + ); +} diff --git a/test/core/time/civil_time_resolver_test.dart b/test/core/time/civil_time_resolver_test.dart index 29b5c252..bdae6d5b 100644 --- a/test/core/time/civil_time_resolver_test.dart +++ b/test/core/time/civil_time_resolver_test.dart @@ -1,10 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:timezone/timezone.dart' as tz; void main() { test('normal civil time resolves to one absolute occurrence', () { final occurrences = CivilTimeResolver.resolve( - DateTime(2026, 2, 1, 12, 30), + DateTime.utc(2026, 2, 1, 12, 30), 'America/New_York', ); @@ -16,7 +17,7 @@ void main() { test('spring-forward gap has no valid occurrence', () { expect( CivilTimeResolver.resolve( - DateTime(2026, 3, 8, 2, 30), + DateTime.utc(2026, 3, 8, 2, 30), 'America/New_York', ), isEmpty, @@ -25,7 +26,7 @@ void main() { test('fall-back overlap exposes first and second occurrence', () { final occurrences = CivilTimeResolver.resolve( - DateTime(2026, 11, 1, 1, 30), + DateTime.utc(2026, 11, 1, 1, 30), 'America/New_York', ); @@ -38,4 +39,166 @@ void main() { DateTime.utc(2026, 11, 1, 6, 30), ]); }); + + test( + 'unknown zone fails explicitly in both resolution and display conversion', + () { + final input = DateTime.utc(2026, 9, 25, 9); + expect( + () => CivilTimeResolver.resolve(input, 'Not/A_Real_Zone'), + throwsA(isA()), + ); + expect( + () => CivilTimeResolver.civilTimeAt(input, 'Not/A_Real_Zone'), + throwsA(isA()), + ); + expect(CivilTimeResolver.resolve(input, 'UTC').single.instantUtc, input); + }, + ); + + test('both overlap occurrences retain seconds and microseconds', () { + final choices = CivilTimeResolver.resolve( + DateTime.utc(2026, 11, 1, 1, 30, 59, 123, 456), + 'America/New_York', + ); + expect(choices.map((value) => value.instantUtc), [ + DateTime.utc(2026, 11, 1, 5, 30, 59, 123, 456), + DateTime.utc(2026, 11, 1, 6, 30, 59, 123, 456), + ]); + expect(choices.map((value) => value.offsetSeconds), [-14400, -18000]); + }); + + test('named-zone display conversion includes the different civil date', () { + final value = CivilTimeResolver.civilTimeAt( + DateTime.utc(2026, 9, 25, 0, 0, 59, 123, 456), + 'America/New_York', + ); + expect( + [ + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, + ], + [2026, 9, 24, 20, 0, 59, 123, 456], + ); + expect(value.timeZoneOffset, const Duration(hours: -4)); + expect(value.toUtc(), DateTime.utc(2026, 9, 25, 0, 0, 59, 123, 456)); + }); + + group('gap proposals are distinct civil drafts', () { + test( + 'New York gap proposes the first valid minute without normalizing input', + () { + final selected = DateTime.utc(2026, 3, 8, 2, 30, 59, 123, 456); + expect( + CivilTimeResolver.resolve(selected, 'America/New_York'), + isEmpty, + ); + final proposed = CivilTimeResolver.nextValidCivilTime( + selected, + 'America/New_York', + ); + expect(proposed, DateTime.utc(2026, 3, 8, 3)); + expect( + proposed!.isUtc, + isTrue, + reason: 'The proposal is a civil carrier, not the occurrence instant', + ); + expect( + CivilTimeResolver.resolve( + proposed, + 'America/New_York', + ).single.instantUtc, + DateTime.utc(2026, 3, 8, 7), + ); + expect(selected, DateTime.utc(2026, 3, 8, 2, 30, 59, 123, 456)); + expect( + CivilTimeResolver.resolve(selected, 'America/New_York'), + isEmpty, + ); + }, + ); + + test( + 'Apia skipped date proposes next calendar date without inventing December 30', + () { + final selected = DateTime.utc(2011, 12, 30, 12, 34, 56, 789, 123); + expect(CivilTimeResolver.resolve(selected, 'Pacific/Apia'), isEmpty); + final proposed = CivilTimeResolver.nextValidCivilTime( + selected, + 'Pacific/Apia', + ); + expect(proposed, DateTime.utc(2011, 12, 31)); + expect( + CivilTimeResolver.resolve( + proposed!, + 'Pacific/Apia', + ).single.instantUtc, + DateTime.utc(2011, 12, 30, 10), + ); + expect(selected, DateTime.utc(2011, 12, 30, 12, 34, 56, 789, 123)); + }, + ); + + test('unknown zone and year overflow provide no invented proposal', () { + expect( + CivilTimeResolver.nextValidCivilTime( + DateTime.utc(2026, 9, 25, 9), + 'Not/A_Real_Zone', + ), + isNull, + ); + expect( + CivilTimeResolver.nextValidCivilTime( + DateTime.utc(9999, 12, 31, 23, 59, 59, 999, 999), + 'UTC', + ), + isNull, + ); + }); + }); + + group('offset labels preserve the stored precision', () { + test( + 'whole, fractional-hour and second offsets have unambiguous signs', + () { + const expected = { + 0: 'UTC+00:00', + -18000: 'UTC-05:00', + 19800: 'UTC+05:30', + 20700: 'UTC+05:45', + 30: 'UTC+00:00:30', + -30: 'UTC-00:00:30', + -2670: 'UTC-00:44:30', + -86400: 'UTC-24:00', + 86400: 'UTC+24:00', + }; + for (final entry in expected.entries) { + expect(CivilTimeResolver.formatUtcOffset(entry.key), entry.value); + } + }, + ); + + test( + 'invalid offset bounds including int64 minimum cannot wrap into a label', + () { + for (final value in [ + -9223372036854775808, + -86401, + 86401, + 9223372036854775807, + ]) { + expect( + () => CivilTimeResolver.formatUtcOffset(value), + throwsFormatException, + ); + } + }, + ); + }); } diff --git a/test/core/time/device_civil_day_test.dart b/test/core/time/device_civil_day_test.dart new file mode 100644 index 00000000..f405432b --- /dev/null +++ b/test/core/time/device_civil_day_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/device_civil_day.dart'; + +void main() { + final zone = Platform.environment['TZ']; + const supported = {'UTC', 'Asia/Seoul', 'America/New_York', 'Pacific/Apia'}; + + void expectInterval( + DeviceCivilDay day, + DateTime start, + DateTime end, + int hours, + ) { + expect(day.startUtc, start); + expect(day.endUtc, end); + expect(day.endUtc.difference(day.startUtc), Duration(hours: hours)); + expect( + day.contains(start.subtract(const Duration(microseconds: 1))), + isFalse, + ); + expect(day.contains(start), isTrue); + expect(day.contains(start.toLocal()), isTrue); + expect(day.contains(end.subtract(const Duration(microseconds: 1))), isTrue); + expect(day.contains(end), isFalse); + } + + group( + 'actual device civil day in $zone', + () { + test('the process actually uses the requested timezone fixture', () { + final expected = switch (zone) { + 'UTC' => (winter: 0, summer: 0), + 'Asia/Seoul' => (winter: 540, summer: 540), + 'America/New_York' => (winter: -300, summer: -240), + 'Pacific/Apia' => (winter: 780, summer: 780), + _ => throw StateError('Unsupported TZ fixture'), + }; + expect( + DateTime(2026, 1, 15, 12).timeZoneOffset.inMinutes, + expected.winter, + ); + expect( + DateTime(2026, 7, 15, 12).timeZoneOffset.inMinutes, + expected.summer, + ); + }); + + test( + 'spring day ends at the next local midnight, not 24 elapsed hours', + () { + final day = DeviceCivilDay.at(DateTime.utc(2026, 3, 8, 12)); + final expected = switch (zone) { + 'UTC' => (DateTime.utc(2026, 3, 8), DateTime.utc(2026, 3, 9), 24), + 'Asia/Seoul' => ( + DateTime.utc(2026, 3, 7, 15), + DateTime.utc(2026, 3, 8, 15), + 24, + ), + 'America/New_York' => ( + DateTime.utc(2026, 3, 8, 5), + DateTime.utc(2026, 3, 9, 4), + 23, + ), + 'Pacific/Apia' => ( + DateTime.utc(2026, 3, 8, 11), + DateTime.utc(2026, 3, 9, 11), + 24, + ), + _ => throw StateError('Unsupported TZ fixture'), + }; + expectInterval(day, expected.$1, expected.$2, expected.$3); + }, + ); + + test( + 'fall day includes both overlap occurrences and excludes next midnight', + () { + final day = DeviceCivilDay.at(DateTime.utc(2026, 11, 1, 12)); + final expected = switch (zone) { + 'UTC' => (DateTime.utc(2026, 11, 1), DateTime.utc(2026, 11, 2), 24), + 'Asia/Seoul' => ( + DateTime.utc(2026, 10, 31, 15), + DateTime.utc(2026, 11, 1, 15), + 24, + ), + 'America/New_York' => ( + DateTime.utc(2026, 11, 1, 4), + DateTime.utc(2026, 11, 2, 5), + 25, + ), + 'Pacific/Apia' => ( + DateTime.utc(2026, 11, 1, 11), + DateTime.utc(2026, 11, 2, 11), + 24, + ), + _ => throw StateError('Unsupported TZ fixture'), + }; + expectInterval(day, expected.$1, expected.$2, expected.$3); + if (zone == 'America/New_York') { + expect(day.contains(DateTime.utc(2026, 11, 1, 5, 30)), isTrue); + expect(day.contains(DateTime.utc(2026, 11, 1, 6, 30)), isTrue); + } + }, + ); + + test( + 'one Seoul appointment belongs to the correct current device date', + () { + final appointment = DateTime.utc(2026, 9, 25); + final day = DeviceCivilDay.at(appointment); + final expectedStart = switch (zone) { + 'UTC' => DateTime.utc(2026, 9, 25), + 'Asia/Seoul' => DateTime.utc(2026, 9, 24, 15), + 'America/New_York' => DateTime.utc(2026, 9, 24, 4), + 'Pacific/Apia' => DateTime.utc(2026, 9, 24, 11), + _ => throw StateError('Unsupported TZ fixture'), + }; + expect(day.startUtc, expectedStart); + expect(day.contains(appointment), isTrue); + expect( + appointment.toLocal().day, + zone == 'America/New_York' ? 24 : 25, + ); + }, + ); + + if (zone == 'Pacific/Apia') { + test( + 'the skipped local date creates no missing or duplicated instant interval', + () { + final transition = DateTime.utc(2011, 12, 30, 10); + final before = transition.subtract(const Duration(microseconds: 1)); + expect(before.toLocal().day, 29); + expect(transition.toLocal().day, 31); + expectInterval( + DeviceCivilDay.at(before), + DateTime.utc(2011, 12, 29, 10), + transition, + 24, + ); + expectInterval( + DeviceCivilDay.at(transition), + transition, + DateTime.utc(2011, 12, 31, 10), + 24, + ); + }, + ); + } + }, + skip: supported.contains(zone) + ? false + : 'Requires explicit TZ=UTC, Asia/Seoul, America/New_York or Pacific/Apia; dedicated matrix is mandatory.', + ); +} diff --git a/test/core/time/recurring_time_correction_mapping_test.dart b/test/core/time/recurring_time_correction_mapping_test.dart new file mode 100644 index 00000000..db68493c --- /dev/null +++ b/test/core/time/recurring_time_correction_mapping_test.dart @@ -0,0 +1,277 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/time/recurring_time_correction_mapping.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; + +final start = DateTime.utc(2030, 1, 7, 10); // Monday. +ScheduleEntity row( + String id, + int ordinal, { + String mask = '', + String prep = 'shared', + bool frozen = false, + DateTime? civil, + String zone = 'UTC', +}) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'office', placeName: 'Office'), + scheduleName: id, + scheduleTime: civil ?? start.add(Duration(days: ordinal - 1)), + timeZoneId: zone, + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: true, + isStarted: frozen, + preparationFrozen: frozen, + startedAt: frozen ? DateTime.utc(2029) : null, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Keep $id', + recurringSegmentId: 'segment', + recurringSlotKey: (civil ?? start.add(Duration(days: ordinal - 1))) + .toIso8601String(), + recurringOrdinal: ordinal, + recurringOverrides: mask, + preparationDefinitionId: prep, +); +RecurringSegment segment({RecurrenceRule? rule}) => RecurringSegment( + id: 'segment', + seriesId: 'series', + rule: + rule ?? + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 5, + ), + schedule: row('template', 1), + preparation: const PreparationEntity(preparationStepList: []), + preparationId: 'shared', + fromSlot: rule?.start ?? start, + createdAt: DateTime.utc(2029), +); +RecurrenceRule replacement({ + int? count = 2, + DateTime? at, + Set days = const {2, 5}, +}) => RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: at ?? start, + timeZoneId: 'UTC', + weekdays: days, + count: count, +); +Future map({ + required List rows, + List exclusions = const [], + RecurrenceRule? rule, + RecurringSegment? source, + ScheduleEntity? anchor, + bool explicit = true, + BackupBudget? budget, + Map own = const {}, +}) => RecurringTimeCorrectionMapper.build( + anchor: anchor ?? rows.first, + segments: [source ?? segment()], + schedules: rows, + exclusions: exclusions, + occurrenceDefinitionOwners: own, + requested: rule ?? replacement(), + endExplicitlyChosen: explicit, + nowUtc: DateTime.utc(2029), + budget: budget ?? BackupBudget(), +); +void main() { + test( + 'ordinary IDs map in stable order; unmatched override/tombstone survive separately', + () async { + final a = row('A', 1), b = row('B', 2), c = row('C', 3, mask: 'name'); + final exclusion = TimeCorrectionExclusion( + 'segment', + start.add(const Duration(days: 3)).toIso8601String(), + 4, + ); + final value = await map( + rows: [c, b, a], + anchor: a, + exclusions: [exclusion], + ); + final byId = {for (final entry in value.rows) entry.original.id: entry}; + expect(byId['A']!.slot!.civilTime, DateTime.utc(2030, 1, 8, 10)); + expect(byId['B']!.slot!.civilTime, DateTime.utc(2030, 1, 11, 10)); + expect(byId['C']!.detached, isTrue); + expect(identical(byId['C']!.original, c), isTrue); + expect(value.exclusions.single.slot, isNull); + expect(identical(value.exclusions.single.original, exclusion), isTrue); + expect(value.rule.count, 2); + }, + ); + test( + 'protected days reserve slots before ordinary rows and excess rows detach', + () async { + final protected = row('run', 2, frozen: true), + a = row('A', 1), + c = row('C', 3); + final value = await map(rows: [a, protected, c]); + expect(value.protectedRows, [protected]); + expect( + value.protectedSlots.single.civilTime, + DateTime.utc(2030, 1, 8, 10), + ); + expect(value.rows.first.original.id, 'A'); + expect(value.rows.first.slot!.civilTime, DateTime.utc(2030, 1, 11, 10)); + expect(value.rows.last.original.id, 'C'); + expect(value.rows.last.detached, isTrue); + expect(protected.recurringSegmentId, 'segment'); + }, + ); + test( + 'a matching tombstone consumes a new ordinal and keeps its original identity', + () async { + final a = row('A', 1), c = row('C', 3); + final exclusion = TimeCorrectionExclusion( + 'segment', + DateTime.utc(2030, 1, 8, 10).toIso8601String(), + 2, + ); + final value = await map(rows: [a, c], exclusions: [exclusion]); + expect(value.exclusions.single.slot!.ordinal, 1); + expect(value.rows.first.slot!.ordinal, 2); + expect(value.rows.last.detached, isTrue); + }, + ); + test( + 'different series preparation detaches without cloning; occurrence-owned override maps', + () async { + final a = row('A', 1), + b = row('B', 2, prep: 'different-shared'), + c = row('C', 5, prep: 'own-C', mask: 'preparation'); + final value = await map(rows: [a, b, c], own: {'own-C': 'C'}); + final byId = {for (final entry in value.rows) entry.original.id: entry}; + expect(byId['B']!.detached, isTrue); + expect(byId['B']!.original.preparationDefinitionId, 'different-shared'); + expect(byId['C']!.slot!.civilTime, DateTime.utc(2030, 1, 11, 10)); + expect(byId['C']!.original.preparationDefinitionId, 'own-C'); + expect(byId['A']!.slot!.civilTime, DateTime.utc(2030, 1, 8, 10)); + }, + ); + test('a finite remaining count includes the validated anchor', () async { + final rows = [for (var i = 1; i <= 5; i++) row('$i', i)]; + final value = await map( + rows: rows, + anchor: rows[2], + explicit: false, + rule: replacement(count: 99, at: rows[2].scheduleTime, days: {3, 4, 5}), + ); + expect(value.rule.count, 3); + expect(value.automaticallyRetainedCount, isTrue); + expect(value.rows.map((entry) => entry.original.id), ['3', '4', '5']); + }); + test( + 'current gap counting cannot silently reuse a changed stored ordinal', + () async { + final oldRule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: DateTime.utc(2030, 3, 8, 2, 30), + timeZoneId: 'America/New_York', + count: 5, + ); + final a = row( + 'anchor', + 4, + civil: DateTime.utc(2030, 3, 11, 2, 30), + zone: 'America/New_York', + ); + await expectLater( + map( + rows: [a], + source: segment(rule: oldRule), + explicit: false, + rule: replacement(at: a.scheduleTime), + ), + throwsA(isA()), + ); + }, + ); + test( + 'an unknown old zone requires explicit count but calendar constraints remain enforced', + () async { + final a = row('anchor', 1, zone: 'Unknown/A10'); + final source = segment( + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'Unknown/A10', + count: 5, + ), + ); + await expectLater( + map(rows: [a], source: source, explicit: false), + throwsA(isA()), + ); + expect((await map(rows: [a], source: source)).rule.count, 2); + }, + ); + test( + 'an unbounded new rule stops after the finite materialized mapping horizon', + () async { + final value = await map( + rows: [row('A', 1), row('B', 2)], + rule: replacement(count: null), + ); + expect(value.rule.count, isNull); + expect(value.rows.every((entry) => !entry.detached), isTrue); + expect(value.workUnits, lessThan(100)); + }, + ); + for (final kind in [ + 'duplicate slot', + 'reverse ordinal', + 'from', + 'count', + 'calendar', + ]) { + test('$kind remains a hard invariant', () async { + final a = row('A', 1); + final b = switch (kind) { + 'duplicate slot' => row('B', 2, civil: start), + 'reverse ordinal' => row( + 'B', + 1, + civil: start.add(const Duration(days: 1)), + ), + 'from' => row('B', 2, civil: start.subtract(const Duration(days: 1))), + 'count' => row('B', 6), + _ => row('B', 2, civil: start.add(const Duration(hours: 1))), + }; + await expectLater( + map(rows: [a, b]), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.dataInvariant, + ), + ), + ); + }); + } + test('explicit count cannot bypass the shared work budget', () async { + final budget = BackupBudget()..visit(BackupLimits.work - 1); + await expectLater( + map(rows: [row('A', 1), row('B', 2)], budget: budget), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + BackupFailureKind.resourceLimit, + ), + ), + ); + }); +} diff --git a/test/core/time/schedule_time_resolution_test.dart b/test/core/time/schedule_time_resolution_test.dart new file mode 100644 index 00000000..990229a8 --- /dev/null +++ b/test/core/time/schedule_time_resolution_test.dart @@ -0,0 +1,364 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; + +ScheduleEntity schedule({ + required DateTime civil, + String zone = 'Asia/Seoul', + int? offset, +}) => ScheduleEntity( + id: 'appointment', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Appointment', + scheduleTime: civil, + timeZoneId: zone, + occurrenceOffsetSeconds: offset, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); + +void main() { + // A10 chooses an instant only when the stored facts/current rules justify it. + // The result is time interpretation, never restore/apply/start permission. + group('actual named-zone resolution', () { + test('future unique null offset resolves through the named zone', () { + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 9, 25, 9, 0, 1, 123, 456)), + nowUtc: DateTime.utc(2026, 9, 1), + ); + + expect(result.status, ScheduleTimeResolutionStatus.resolved); + expect(result.instantUtc, DateTime.utc(2026, 9, 25, 0, 0, 1, 123, 456)); + expect(result.isHistorical, isFalse); + expect(result.occurrences.map((o) => o.offsetSeconds), [32400]); + }); + + test('unknown zone cannot silently become UTC', () { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 9, 25, 9), + zone: 'Not/A_Real_Zone', + offset: 0, + ), + nowUtc: DateTime.utc(2026, 9, 1), + ); + + expect(result.status, ScheduleTimeResolutionStatus.unknownZone); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + }); + + test('explicit UTC is a valid choice', () { + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 9, 25, 9), zone: 'UTC', offset: 0), + nowUtc: DateTime.utc(2026, 9, 1), + ); + + expect(result.status, ScheduleTimeResolutionStatus.resolved); + expect(result.instantUtc, DateTime.utc(2026, 9, 25, 9)); + }); + + test('overlap without a selection exposes both actual choices', () { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 11, 1, 1, 30), + zone: 'America/New_York', + ), + nowUtc: DateTime.utc(2026, 10, 1), + ); + + expect(result.status, ScheduleTimeResolutionStatus.ambiguous); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + expect(result.occurrences.map((o) => o.instantUtc), [ + DateTime.utc(2026, 11, 1, 5, 30), + DateTime.utc(2026, 11, 1, 6, 30), + ]); + }); + + test('each explicitly selected overlap occurrence retains its instant', () { + for (final choice in [ + (offset: -14400, instant: DateTime.utc(2026, 11, 1, 5, 30)), + (offset: -18000, instant: DateTime.utc(2026, 11, 1, 6, 30)), + ]) { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 11, 1, 1, 30), + zone: 'America/New_York', + offset: choice.offset, + ), + nowUtc: DateTime.utc(2026, 10, 1), + ); + expect(result.status, ScheduleTimeResolutionStatus.resolved); + expect(result.instantUtc, choice.instant); + } + }); + + test( + 'stale overlap selection does not choose either replacement silently', + () { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 11, 1, 1, 30), + zone: 'America/New_York', + offset: -21600, + ), + nowUtc: DateTime.utc(2026, 10, 1), + ); + + expect(result.status, ScheduleTimeResolutionStatus.changed); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + expect(result.occurrences.map((o) => o.offsetSeconds), [ + -14400, + -18000, + ]); + }, + ); + + test( + 'future gap is not normalized even when an old offset was supplied', + () { + for (final offset in [null, -18000]) { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 3, 8, 2, 30), + zone: 'America/New_York', + offset: offset, + ), + nowUtc: DateTime.utc(2026, 3, 1), + ); + expect(result.status, ScheduleTimeResolutionStatus.nonexistent); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + expect(result.occurrences, isEmpty); + } + }, + ); + + test('45-minute and 30-minute-zone offsets are not rounded to hours', () { + final kathmandu = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 9, 25, 9), zone: 'Asia/Kathmandu'), + nowUtc: DateTime.utc(2026, 9, 1), + ); + final lordHowe = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 7, 1, 9), + zone: 'Australia/Lord_Howe', + ), + nowUtc: DateTime.utc(2026, 6, 1), + ); + + expect(kathmandu.status, ScheduleTimeResolutionStatus.resolved); + expect(kathmandu.instantUtc, DateTime.utc(2026, 9, 25, 3, 15)); + expect(lordHowe.status, ScheduleTimeResolutionStatus.resolved); + expect(lordHowe.instantUtc, DateTime.utc(2026, 6, 30, 22, 30)); + }); + }); + + group('stored history does not acquire invented certainty', () { + test('past explicit offset survives disagreement with current rules', () { + // Synthetic stored +10:00; this does not claim Seoul used it historically. + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 1, 1, 9), offset: 36000), + nowUtc: DateTime.utc(2026, 1, 3), + ); + + expect(result.status, ScheduleTimeResolutionStatus.resolved); + expect(result.instantUtc, DateTime.utc(2025, 12, 31, 23)); + expect(result.isHistorical, isTrue); + expect(result.proposedInstantUtc, isNull); + }); + + test( + 'old null offset stays uncertain even for a currently unique zone', + () { + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 1, 1, 9)), + nowUtc: DateTime.utc(2026, 1, 3), + ); + + expect(result.status, ScheduleTimeResolutionStatus.historicalUncertain); + expect(result.isHistorical, isTrue); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + }, + ); + + test( + 'all known candidates in the past do not fabricate a historical offset', + () { + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 9, 25, 9)), + nowUtc: DateTime.utc(2026, 9, 25, 1), + ); + + expect(result.status, ScheduleTimeResolutionStatus.historicalUncertain); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + expect(result.isHistorical, isTrue); + }, + ); + + test('an old gap with no stored offset remains uncertain history', () { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 3, 8, 2, 30), + zone: 'America/New_York', + ), + nowUtc: DateTime.utc(2026, 3, 10), + ); + + expect(result.status, ScheduleTimeResolutionStatus.historicalUncertain); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + }); + + test('overlap straddling now still requires a choice', () { + final result = ScheduleTimeResolver.resolve( + schedule( + civil: DateTime.utc(2026, 11, 1, 1, 30), + zone: 'America/New_York', + ), + nowUtc: DateTime.utc(2026, 11, 1, 6), + ); + + expect(result.status, ScheduleTimeResolutionStatus.ambiguous); + expect(result.instantUtc, isNull); + expect(result.isHistorical, isFalse); + expect(result.occurrences, hasLength(2)); + }); + + test('unique candidate exactly now is not silently relabeled past', () { + final instant = DateTime.utc(2026, 9, 25); + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 9, 25, 9)), + nowUtc: instant, + ); + + expect(result.status, ScheduleTimeResolutionStatus.resolved); + expect(result.instantUtc, instant); + expect(result.isHistorical, isFalse); + }); + + test( + 'started frozen and completed facts protect explicit future history', + () { + final base = schedule( + civil: DateTime.utc(2026, 9, 25, 9), + offset: 36000, + ); + final began = DateTime.utc(2026, 8, 31); + for (final protected in [ + base.copyWith( + isStarted: true, + preparationFrozen: true, + startedAt: began, + ), + base.copyWith(preparationFrozen: true, startedAt: began), + base.copyWith( + doneStatus: ScheduleDoneStatus.normalEnd, + startedAt: began, + finishedAt: DateTime.utc(2026, 8, 31, 1), + ), + ]) { + final result = ScheduleTimeResolver.resolve( + protected, + nowUtc: DateTime.utc(2026, 9, 1), + ); + expect(result.status, ScheduleTimeResolutionStatus.resolved); + expect(result.instantUtc, DateTime.utc(2026, 9, 24, 23)); + expect(result.isHistorical, isTrue); + expect(result.proposedInstantUtc, isNull); + } + }, + ); + }); + + group('rule change and invalid representations', () { + test('new unique rules propose a replacement without activating it', () { + final value = schedule( + civil: DateTime.utc(2026, 9, 25, 12), + zone: 'UTC', + offset: 0, + ); + final original = ScheduleTimeResolver.resolve( + value, + nowUtc: DateTime.utc(2026, 9, 1), + lookup: (_, _) => [ + CivilTimeOccurrence( + offsetSeconds: 0, + instantUtc: DateTime.utc(2026, 9, 25, 12), + ), + ], + ); + final changed = ScheduleTimeResolver.resolve( + value, + nowUtc: DateTime.utc(2026, 9, 1), + lookup: (_, _) => [ + CivilTimeOccurrence( + offsetSeconds: 3600, + instantUtc: DateTime.utc(2026, 9, 25, 11), + ), + ], + ); + + expect(original.status, ScheduleTimeResolutionStatus.resolved); + expect(original.instantUtc, DateTime.utc(2026, 9, 25, 12)); + expect(changed.status, ScheduleTimeResolutionStatus.changed); + expect(changed.instantUtc, isNull); + expect(changed.proposedInstantUtc, DateTime.utc(2026, 9, 25, 11)); + expect(changed.isHistorical, isFalse); + }); + + test( + 'invalid offset integers return invalid rather than a wrapped instant', + () { + for (final offset in [ + 86401, + -86401, + 9223372036854775807, + -9223372036854775808, + ]) { + final result = ScheduleTimeResolver.resolve( + schedule(civil: DateTime.utc(2026, 9, 25, 9), offset: offset), + nowUtc: DateTime.utc(2026, 9, 1), + ); + expect( + result.status, + ScheduleTimeResolutionStatus.invalid, + reason: '$offset', + ); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + } + }, + ); + + test('civil and selected instant both obey persisted year bounds', () { + for (final value in [ + schedule(civil: DateTime.utc(0), zone: 'UTC', offset: 0), + schedule(civil: DateTime.utc(10000), zone: 'UTC', offset: 0), + schedule(civil: DateTime.utc(1), zone: 'UTC', offset: 1), + schedule( + civil: DateTime.utc(9999, 12, 31, 23, 59, 59, 999, 999), + zone: 'UTC', + offset: -1, + ), + ]) { + final result = ScheduleTimeResolver.resolve( + value, + nowUtc: DateTime.utc(2026, 9, 1), + ); + expect(result.status, ScheduleTimeResolutionStatus.invalid); + expect(result.instantUtc, isNull); + expect(result.proposedInstantUtc, isNull); + } + }); + }); +} diff --git a/test/core/time/time_correction_conflicts_test.dart b/test/core/time/time_correction_conflicts_test.dart new file mode 100644 index 00000000..79883b49 --- /dev/null +++ b/test/core/time/time_correction_conflicts_test.dart @@ -0,0 +1,240 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/time/time_correction_conflicts.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; + +void main() { + final now = DateTime.utc(2030, 1, 1); + final start = DateTime.utc(2030, 1, 2, 10); + ScheduleEntity row( + String id, + DateTime at, { + String zone = 'UTC', + int offset = 0, + }) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Office'), + scheduleName: id, + scheduleTime: at, + timeZoneId: zone, + occurrenceOffsetSeconds: offset, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + isChanged: false, + isStarted: false, + ); + RecurrenceRule rule({int? count = 2, DateTime? at}) => RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: at ?? start, + timeZoneId: 'UTC', + count: count, + ); + Future scan({ + RecurrenceRule? targetRule, + List stored = const [], + List segments = const [], + Map> excludedOthers = const {}, + Set excluded = const {}, + Duration preparation = const Duration(minutes: 10), + BackupBudget? budget, + }) => TimeCorrectionConflictScanner.scan( + rule: targetRule ?? rule(), + base: row('base', start), + basePreparation: preparation, + proposedOverrides: {}, + proposedPreparationById: {}, + excludedSlots: excluded, + storedOthers: stored, + otherSegments: segments, + otherExcludedSlots: excludedOthers, + nowUtc: now, + budget: budget ?? BackupBudget(), + ); + + test( + 'inclusive end date checks its final evening and ambiguity remains a possible impact', + () async { + final last = DateTime.utc(2030, 1, 3, 23, 59, 59, 999, 999); + final untilRule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: DateTime.utc(2030, 1, 2, 23, 59, 59, 999, 999), + timeZoneId: 'UTC', + until: DateTime.utc(2030, 1, 3), + ); + final proof = await scan( + targetRule: untilRule, + stored: [TimeCorrectionBusyInterval(row('last', last), last, last)], + ); + expect(proof.conflicts.single.slot.instantUtc, last); + final overlap = DateTime.utc(2030, 11, 3, 1, 30); + final segment = RecurringSegment( + id: 'ambiguous', + seriesId: 'series', + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: overlap, + timeZoneId: 'America/New_York', + count: 1, + ), + schedule: row('ambiguous', overlap), + preparation: const PreparationEntity(preparationStepList: []), + preparationId: 'definition', + fromSlot: overlap, + createdAt: now, + ); + final selected = rule(count: 1, at: DateTime.utc(2030, 11, 3, 6)); + final uncertain = await scan(targetRule: selected, segments: [segment]); + expect(uncertain.conflicts, isEmpty); + expect(uncertain.possibleOverlaps, hasLength(1)); + expect(uncertain.possibleOverlaps.single.originalCivil, overlap); + expect(uncertain.possibleOverlaps.single.timeZoneId, 'America/New_York'); + expect( + (await scan( + targetRule: selected, + segments: [segment], + excludedOthers: { + 'ambiguous': {overlap.toIso8601String()}, + }, + )).possibleOverlaps, + isEmpty, + ); + expect( + (await scan( + targetRule: rule(count: 1, at: DateTime.utc(2030, 11, 3, 9)), + segments: [segment], + )).possibleOverlaps, + isEmpty, + ); + }, + ); + + test( + 'equal target instants conflict even with zero preparations and foreign civil dates', + () async { + final ny = row( + 'new-york', + DateTime.utc(2030, 1, 2, 5), + zone: 'America/New_York', + offset: -18000, + ); + final proof = await scan( + preparation: Duration.zero, + stored: [TimeCorrectionBusyInterval(ny, start, start)], + ); + expect(proof.conflicts, hasLength(1)); + expect(proof.conflicts.single.slot.instantUtc, start); + expect(proof.conflicts.single.other.schedule.id, 'new-york'); + }, + ); + test( + 'touching preparation endpoints do not overlap while one microsecond inside does', + () async { + final target = start.subtract(const Duration(minutes: 10)); + final exact = TimeCorrectionBusyInterval( + row('touch', target), + target, + target, + ); + expect((await scan(stored: [exact])).conflicts, isEmpty); + final inside = target.add(const Duration(microseconds: 1)); + expect( + (await scan( + stored: [ + TimeCorrectionBusyInterval(row('inside', inside), inside, inside), + ], + )).conflicts, + hasLength(1), + ); + }, + ); + test( + 'other unmaterialized recurrence is expanded read-only and respects its tombstone', + () async { + final segment = RecurringSegment( + id: 'other', + seriesId: 'series', + rule: rule(), + schedule: row('otherbase', start), + preparation: const PreparationEntity(preparationStepList: []), + preparationId: 'definition', + fromSlot: start, + createdAt: now, + ); + final proof = await scan( + segments: [segment], + excludedOthers: { + 'other': {start.toIso8601String()}, + }, + ); + expect(proof.conflicts, hasLength(1)); + expect(proof.conflicts.single.slot.ordinal, 2); + expect(proof.conflicts.single.other.schedule.recurringSegmentId, 'other'); + }, + ); + test( + 'an excluded proposed slot still consumes count and never creates a replacement fourth occurrence', + () async { + final other = start.add(const Duration(days: 2)); + final proof = await scan( + excluded: {start.toIso8601String()}, + stored: [TimeCorrectionBusyInterval(row('third', other), other, other)], + ); + expect(proof.conflicts, isEmpty); + }, + ); + test( + 'unbounded self overlap is marked persistent rather than hidden by finite preview', + () async { + final proof = await scan( + targetRule: rule(count: null), + preparation: const Duration(hours: 25), + ); + expect(proof.conflicts, isNotEmpty); + expect(proof.conflicts.any((e) => e.persistent), isTrue); + expect(proof.through.isAfter(start.add(const Duration(days: 7))), isTrue); + }, + ); + test( + 'exhausted shared work budget cannot return a partial conflict-free result', + () async { + final budget = BackupBudget()..visit(BackupLimits.work); + await expectLater( + scan(budget: budget), + throwsA(isA()), + ); + }, + ); + test( + 'materialized override occupies its slot instead of also generating its old base target', + () async { + final segment = RecurringSegment( + id: 'other', + seriesId: 'series', + rule: rule(count: 1), + schedule: row('otherbase', start), + preparation: const PreparationEntity(preparationStepList: []), + preparationId: 'definition', + fromSlot: start, + createdAt: now, + ); + final target = start.add(const Duration(hours: 4)); + final moved = row('stable-id', target).copyWith( + recurringSegmentId: 'other', + recurringSlotKey: start.toIso8601String(), + recurringOrdinal: 1, + recurringOverrides: 'time', + ); + final proof = await scan( + segments: [segment], + stored: [TimeCorrectionBusyInterval(moved, target, target)], + ); + expect(proof.conflicts, isEmpty); + }, + ); +} diff --git a/test/core/time/time_zone_rules_test.dart b/test/core/time/time_zone_rules_test.dart new file mode 100644 index 00000000..8eae835a --- /dev/null +++ b/test/core/time/time_zone_rules_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_value_validation.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:timezone/timezone.dart' as tz; + +const ownedZoneName = 'Test/A10_Owned_Rules'; + +tz.Location fixedRules(int offsetMilliseconds) => tz.Location( + ownedZoneName, + [], + [], + [tz.TimeZone(offsetMilliseconds, isDst: false, abbreviation: 'A10')], +); + +tz.Location changingRules() => tz.Location( + ownedZoneName, + [ + DateTime.utc(2040).millisecondsSinceEpoch, + DateTime.utc(2050).millisecondsSinceEpoch, + ], + [0, 1], + [ + const tz.TimeZone(0, isDst: false, abbreviation: 'A10A'), + const tz.TimeZone(3600000, isDst: true, abbreviation: 'A10B'), + ], +); + +void main() { + late Map originalLocations; + late tz.Location originalLocal; + + setUp(() { + TimeZoneRules.ensureInitialized(); + originalLocations = Map.of(tz.timeZoneDatabase.locations); + originalLocal = tz.local; + }); + + tearDown(() { + // Mutations below touch only owned synthetic locations. Restore the same + // global map and local pointer so no subsequent test inherits our fixtures. + tz.timeZoneDatabase.locations + ..clear() + ..addAll(originalLocations); + tz.setLocalLocation(originalLocal); + }); + + test('known and unknown lookup agree without inventing a UTC fallback', () { + expect(TimeZoneRules.contains('Asia/Seoul'), isTrue); + expect(TimeZoneRules.location('Asia/Seoul').name, 'Asia/Seoul'); + expect(TimeZoneRules.contains('UTC'), isTrue); + expect(TimeZoneRules.location('UTC').name, 'UTC'); + expect(TimeZoneRules.contains('Not/A_Real_Zone'), isFalse); + expect( + () => TimeZoneRules.location('Not/A_Real_Zone'), + throwsA(isA()), + ); + }); + + test('unchanged loaded content retains its identity', () { + final before = TimeZoneRules.loadedIdentity; + TimeZoneRules.ensureInitialized(); + TimeZoneRules.location('Asia/Seoul'); + expect(TimeZoneRules.loadedIdentity, before); + }); + + test( + 'backup validation shares the loaded rules instead of reinitializing them', + () { + tz.timeZoneDatabase.locations[ownedZoneName] = fixedRules(3600000); + final before = TimeZoneRules.loadedIdentity; + + expect( + () => BackupValueValidation.namedZone(ownedZoneName), + returnsNormally, + ); + expect( + TimeZoneRules.location(ownedZoneName).zones.single.offset, + 3600000, + ); + expect(TimeZoneRules.loadedIdentity, before); + }, + ); + + test( + 'replacing a location in the same map changes interpretation and identity', + () { + final map = tz.timeZoneDatabase.locations; + map[ownedZoneName] = fixedRules(0); + final before = TimeZoneRules.loadedIdentity; + final value = ScheduleEntity( + id: 'appointment', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Appointment', + scheduleTime: DateTime.utc(2026, 9, 25, 12), + timeZoneId: ownedZoneName, + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + final first = ScheduleTimeResolver.resolve( + value, + nowUtc: DateTime.utc(2026, 9, 1), + ); + expect(first.status, ScheduleTimeResolutionStatus.resolved); + expect(first.instantUtc, DateTime.utc(2026, 9, 25, 12)); + + // No lookup fake: the resolver sees the actual replaced registry entry. + map[ownedZoneName] = fixedRules(3600000); + expect(identical(map, tz.timeZoneDatabase.locations), isTrue); + expect(TimeZoneRules.loadedIdentity, isNot(before)); + final second = ScheduleTimeResolver.resolve( + value, + nowUtc: DateTime.utc(2026, 9, 1), + ); + expect(second.status, ScheduleTimeResolutionStatus.changed); + expect(second.instantUtc, isNull); + expect(second.proposedInstantUtc, DateTime.utc(2026, 9, 25, 11)); + }, + ); + + test( + 'changing a transition instant in place invalidates the old rule identity', + () { + final location = changingRules(); + tz.timeZoneDatabase.locations[ownedZoneName] = location; + final before = TimeZoneRules.loadedIdentity; + final instant = DateTime.utc(2045).millisecondsSinceEpoch; + expect(location.timeZone(instant).offset, 0); + + location.transitionAt[1] = DateTime.utc(2044).millisecondsSinceEpoch; + expect( + identical(TimeZoneRules.location(ownedZoneName), location), + isTrue, + ); + expect(location.timeZone(instant).offset, 3600000); + expect(TimeZoneRules.loadedIdentity, isNot(before)); + }, + ); + + test('changing the transition zone index in place changes rule identity', () { + final location = changingRules(); + tz.timeZoneDatabase.locations[ownedZoneName] = location; + final before = TimeZoneRules.loadedIdentity; + final instant = DateTime.utc(2055).millisecondsSinceEpoch; + expect(location.timeZone(instant).offset, 3600000); + + location.transitionZone[1] = 0; + expect(location.timeZone(instant).offset, 0); + expect(TimeZoneRules.loadedIdentity, isNot(before)); + }); + + test( + 'changing a zone offset inside an existing location changes identity', + () { + final location = changingRules(); + tz.timeZoneDatabase.locations[ownedZoneName] = location; + final before = TimeZoneRules.loadedIdentity; + final instant = DateTime.utc(2055).millisecondsSinceEpoch; + expect(location.timeZone(instant).offset, 3600000); + + location.zones[1] = const tz.TimeZone( + 7200000, + isDst: true, + abbreviation: 'A10B', + ); + expect(location.timeZone(instant).offset, 7200000); + expect(TimeZoneRules.loadedIdentity, isNot(before)); + }, + ); + + test('equivalent content in a new object preserves rule identity', () { + tz.timeZoneDatabase.locations[ownedZoneName] = changingRules(); + final before = TimeZoneRules.loadedIdentity; + tz.timeZoneDatabase.locations[ownedZoneName] = changingRules(); + expect(TimeZoneRules.loadedIdentity, before); + }); + + test('map insertion order is not a rule change', () { + final before = TimeZoneRules.loadedIdentity; + final firstName = tz.timeZoneDatabase.locations.keys.first; + final firstLocation = tz.timeZoneDatabase.locations.remove(firstName)!; + tz.timeZoneDatabase.locations[firstName] = firstLocation; + expect(TimeZoneRules.loadedIdentity, before); + }); + + test('removing a location invalidates its identity and lookup authority', () { + tz.timeZoneDatabase.locations[ownedZoneName] = fixedRules(0); + final before = TimeZoneRules.loadedIdentity; + tz.timeZoneDatabase.locations.remove(ownedZoneName); + expect(TimeZoneRules.loadedIdentity, isNot(before)); + expect(TimeZoneRules.contains(ownedZoneName), isFalse); + expect( + () => TimeZoneRules.location(ownedZoneName), + throwsA(isA()), + ); + }); + + test('device local display zone does not change loaded rule identity', () { + final before = TimeZoneRules.loadedIdentity; + tz.setLocalLocation(TimeZoneRules.location('Asia/Seoul')); + expect(TimeZoneRules.loadedIdentity, before); + tz.setLocalLocation(TimeZoneRules.location('America/New_York')); + expect(TimeZoneRules.loadedIdentity, before); + }); +} diff --git a/test/data/daos/civil_schedule_storage_test.dart b/test/data/daos/civil_schedule_storage_test.dart new file mode 100644 index 00000000..fb6e1363 --- /dev/null +++ b/test/data/daos/civil_schedule_storage_test.dart @@ -0,0 +1,83 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/mappers/recurrence_codec.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final example in [ + (literal: '2026-03-08T02:30:04.123456', zone: 'Asia/Seoul', offset: 32400), + ( + literal: '2026-11-01T01:30:00.000', + zone: 'America/New_York', + offset: -14400, + ), + ( + literal: '2026-11-01T01:30:00.000', + zone: 'America/New_York', + offset: -18000, + ), + (literal: '2011-12-30T12:00:00.000', zone: 'Asia/Seoul', offset: 32400), + ]) { + test( + 'actual SQLite and recurrence codec preserve ${example.literal}/${example.offset}', + () async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(database.close); + final civil = CivilDateTime.parse(example.literal); + final value = ScheduleEntity( + id: 'civil', + place: const PlaceEntity(id: 'place', placeName: 'Place'), + scheduleName: 'Original', + scheduleTime: civil.toUtcCarrier(), + timeZoneId: example.zone, + occurrenceOffsetSeconds: example.offset, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleNote: '', + ); + await database.scheduleDao.createSchedule( + value.toScheduleWithPlaceRow(), + ); + final read = (await database.scheduleDao.getScheduleById( + value.id, + )).toScheduleEntity(); + expect(CivilDateTime.fromFields(read.scheduleTime), civil); + expect(read.occurrenceOffsetSeconds, example.offset); + expect(read.occurrenceInstantUtc, civil.atOffset(example.offset)); + await database.scheduleDao.updateScheduleWithPlace( + read.copyWith(scheduleNote: 'Text only').toScheduleWithPlaceRow(), + ); + final raw = await database + .customSelect( + 'SELECT schedule_time, occurrence_offset_seconds FROM schedules', + ) + .getSingle(); + expect(raw.read('schedule_time'), example.literal); + expect(raw.read('occurrence_offset_seconds'), example.offset); + final wire = RecurrenceCodec.scheduleToJson(read); + expect(wire['time'], example.literal); + final decoded = RecurrenceCodec.scheduleFromJson(wire); + expect(CivilDateTime.fromFields(decoded.scheduleTime), civil); + expect(decoded.occurrenceInstantUtc, civil.atOffset(example.offset)); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: civil.toUtcCarrier(), + timeZoneId: example.zone, + ); + expect( + RecurrenceCodec.ruleFromJson(RecurrenceCodec.ruleToJson(rule)), + rule, + ); + }, + ); + } +} diff --git a/test/data/daos/schedule_dao_test.dart b/test/data/daos/schedule_dao_test.dart index a507af0f..8738752a 100644 --- a/test/data/daos/schedule_dao_test.dart +++ b/test/data/daos/schedule_dao_test.dart @@ -24,6 +24,7 @@ void main() async { final endDate = scheduleTime.add(Duration(days: 1)); final scheduleModel = Schedule( + requiresStartConfirmation: false, id: scheduleEntityId, placeId: placeModel.id, scheduleName: 'Test Schedule', diff --git a/test/data/data_sources/alarm_registry_local_data_source_test.dart b/test/data/data_sources/alarm_registry_local_data_source_test.dart index b20262c3..3b6e2e28 100644 --- a/test/data/data_sources/alarm_registry_local_data_source_test.dart +++ b/test/data/data_sources/alarm_registry_local_data_source_test.dart @@ -200,7 +200,7 @@ ScheduledAlarmRecord _record(String scheduleId) { payload: { 'type': 'schedule_alarm', 'scheduleId': scheduleId, - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'alarm', 'detailedNotificationContent': 'false', }, diff --git a/test/data/data_sources/schedule_local_data_source_test.dart b/test/data/data_sources/schedule_local_data_source_test.dart index 49e3b7c6..a06d8591 100644 --- a/test/data/data_sources/schedule_local_data_source_test.dart +++ b/test/data/data_sources/schedule_local_data_source_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/domain/entities/civil_date_time.dart'; import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -32,7 +33,10 @@ void main() { expect(stored.schedule.id, 'schedule-1'); expect(stored.place, const Place(id: 'place-1', placeName: 'Office')); expect(stored.schedule.scheduleName, 'Meeting schedule-1'); - expect(stored.schedule.scheduleTime, DateTime(2026, 5, 15, 9)); + expect( + CivilDateTime.fromFields(stored.schedule.scheduleTime), + CivilDateTime.fromFields(schedule.schedule.scheduleTime), + ); expect(stored.schedule.moveTime, const Duration(minutes: 20)); expect(stored.schedule.scheduleSpareTime, const Duration(minutes: 5)); }); @@ -74,7 +78,10 @@ void main() { expect(schedules.map((schedule) => schedule.schedule.id), ['inside']); expect(schedules.single.schedule.scheduleName, 'Updated meeting'); - expect(schedules.single.schedule.scheduleTime, DateTime(2026, 5, 15, 10)); + expect( + CivilDateTime.fromFields(schedules.single.schedule.scheduleTime), + CivilDateTime.fromFields(updated.scheduleTime), + ); expect(schedules.single.schedule.moveTime, const Duration(minutes: 45)); expect(schedules.single.schedule.isChanged, isTrue); expect(schedules.single.schedule.isStarted, isTrue); @@ -203,6 +210,7 @@ Schedule _scheduleRow({ required int latenessTime, }) { return Schedule( + requiresStartConfirmation: false, id: id, placeId: placeId, scheduleName: scheduleName, diff --git a/test/data/mappers/domain_persistence_mappers_test.dart b/test/data/mappers/domain_persistence_mappers_test.dart index d75f4e91..b4357a2d 100644 --- a/test/data/mappers/domain_persistence_mappers_test.dart +++ b/test/data/mappers/domain_persistence_mappers_test.dart @@ -14,6 +14,7 @@ void main() { () { final entity = ScheduleWithPlace( schedule: Schedule( + requiresStartConfirmation: false, id: 'schedule-model', placeId: 'place-model', scheduleName: 'Doctor', @@ -64,6 +65,8 @@ void main() { alarmsEnabled: true, alarmOffsetMinutes: 5, detailedNotificationContent: false, + restoreCleanupPending: false, + rejectLegacyDelivery: false, dataRevision: 7, ); diff --git a/test/data/repositories/default_preferences_profile_publication_test.dart b/test/data/repositories/default_preferences_profile_publication_test.dart new file mode 100644 index 00000000..460103a0 --- /dev/null +++ b/test/data/repositories/default_preferences_profile_publication_test.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/repositories/user_repository_impl.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +class _Latch extends QueryInterceptor { + bool armed = false; + int reads = 0; + final entered = Completer(); + final release = Completer(); + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await executor.runSelect(statement, args); + if (armed && statement.contains('FROM "users"') && ++reads == 2) { + armed = false; + entered.complete(); + await release.future; + } + return result; + } +} + +void main() { + test( + 'late same-store profile reload cannot overwrite newer observed preferences', + () async { + final latch = _Latch(); + final db = AppDatabase.forTesting( + NativeDatabase.memory().interceptWith(latch), + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 10), + note: '', + ), + ); + final users = UserRepositoryImpl(db); + final observed = []; + final sub = users.userStream.listen((u) { + if (u.valueOrNull != null) observed.add(u.spareTime.inMinutes); + }); + addTearDown(() async { + await sub.cancel(); + await users.dispose(); + await db.close(); + }); + await users.getUser(); + await users.userStream.firstWhere( + (u) => u.spareTimeOrNull == const Duration(minutes: 10), + ); + latch.armed = true; + final reload = users.getUser(); + await latch.entered.future; + final newObserved = users.userStream.firstWhere( + (u) => u.spareTimeOrNull == const Duration(minutes: 20), + ); + await users.updateSpareTime(const Duration(minutes: 20)); + await newObserved; + latch.release.complete(); + final returned = await reload; + await Future.delayed(Duration.zero); + expect( + returned.spareTime, + const Duration(minutes: 10), + reason: 'The one-shot return is the snapshot that call actually read.', + ); + expect( + observed.last, + 20, + reason: + 'The already published newer preference must not be overwritten by an older reload.', + ); + expect((await users.getUser()).spareTime, const Duration(minutes: 20)); + await users.updateSpareTime(const Duration(minutes: 25)); + await users.userStream.firstWhere( + (u) => u.spareTimeOrNull == const Duration(minutes: 25), + ); + expect( + observed.last, + 25, + reason: + 'Suppressing stale publication cannot starve later normal reads or watches.', + ); + }, + ); +} diff --git a/test/data/repositories/default_preferences_repository_test.dart b/test/data/repositories/default_preferences_repository_test.dart new file mode 100644 index 00000000..7da3b016 --- /dev/null +++ b/test/data/repositories/default_preferences_repository_test.dart @@ -0,0 +1,768 @@ +import 'dart:async'; +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/repositories/default_preferences_repository_impl.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/default_preferences_workflow.dart'; +import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; + +PreparationEntity preparation([int minutes = 5]) => PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Pack', + preparationTime: Duration(minutes: minutes), + ), + ], +); + +class _Load extends Fake implements LoadUserUseCase { + int calls = 0; + Future Function()? handler; + @override + Future call() async { + calls++; + await handler?.call(); + } +} + +class _Effects extends Fake implements ScheduleMutationAlarmEffectsCoordinator { + int calls = 0; + Future Function()? handler; + @override + Future afterCommit() async { + calls++; + return await handler?.call() ?? true; + } +} + +class _Barrier extends QueryInterceptor { + bool armed = false; + final entered = Completer(); + final release = Completer(); + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + if (armed && statement.contains('FROM "preparation_users"')) { + armed = false; + entered.complete(); + await release.future; + } + return executor.runSelect(statement, args); + } +} + +class _AuthorityFault extends QueryInterceptor { + _AuthorityFault(this.failAt); + final int failAt; + bool committedWrite = false; + int reads = 0; + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await executor.runUpdate(statement, args); + if (statement.contains('data_revision = data_revision + 1')) { + committedWrite = true; + } + return result; + } + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + if (committedWrite && + statement.contains('FROM "users"') && + ++reads == failAt) { + throw StateError('injected authority SELECT failure'); + } + return executor.runSelect(statement, args); + } +} + +void main() { + late AppDatabase db; + late LocalDataOperationGate gate; + late DefaultPreferencesRepositoryImpl repo; + late _Load load; + late _Effects effects; + late DefaultPreferencesWorkflow workflow; + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + gate = LocalDataOperationGate(); + repo = DefaultPreferencesRepositoryImpl(db, gate: gate); + load = _Load(); + effects = _Effects(); + workflow = DefaultPreferencesWorkflow(repo, load, effects); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 10), + note: 'retain me', + ), + ); + await db.preparationUserDao.createPreparationUser( + preparation(), + 'local-profile', + ); + await db + .into(db.places) + .insert( + PlacesCompanion.insert(id: const Value('place'), placeName: 'Office'), + ); + await db + .into(db.schedules) + .insert( + SchedulesCompanion.insert( + id: const Value('dependent'), + placeId: 'place', + scheduleName: 'Meeting', + scheduleTime: DateTime.utc(2030), + moveTime: Duration.zero, + ), + ); + }); + tearDown(() async { + await db.close(); + gate.dispose(); + }); + Future profile() => (db.select( + db.users, + )..where((u) => u.id.equals('local-profile'))).getSingle(); + Future input({ + int minutes = 8, + int spare = 20, + }) async => DefaultPreferencesSubmission( + baseline: await repo.read(), + preparation: preparation(minutes), + spareTime: Duration(minutes: spare), + ); + + test( + 'atomic pair commits one revision and effect sees both values', + () async { + final before = await profile(); + effects.handler = () async { + expect((await profile()).spareTime, 20); + expect((await repo.read()).preparation.totalDuration.inMinutes, 8); + return true; + }; + final receipt = await workflow.save(await input()); + final after = await profile(); + expect(receipt.complete, isTrue); + expect(receipt.changed, isTrue); + expect(after.dataRevision, before.dataRevision + 1); + expect(after.note, 'retain me'); + expect(effects.calls, 1); + expect(load.calls, 1); + }, + ); + for (final target in [ + 'DELETE ON preparation_users', + 'INSERT ON preparation_users', + 'UPDATE OF spare_time ON users', + 'UPDATE OF data_revision ON users', + ]) { + test( + '$target fault rolls back pair, revision and dependency metadata', + () async { + final before = await profile(); + final schedule = await db.select(db.schedules).getSingle(); + final old = await repo.read(); + await db.customStatement( + "CREATE TRIGGER injected BEFORE $target BEGIN SELECT RAISE(ABORT,'private fault path'); END", + ); + await expectLater( + workflow.save(await input()), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + DefaultPreferencesFailure.failed, + ), + ), + ); + expect(await profile(), before); + expect((await repo.read()).preparation, old.preparation); + expect(await db.select(db.schedules).getSingle(), schedule); + expect(effects.calls, 0); + expect(load.calls, 0); + }, + ); + } + test('no-op does not touch rows, dependencies, revision or effects', () async { + final before = await profile(); + final schedule = await db.select(db.schedules).getSingle(); + await db.customStatement( + "CREATE TRIGGER reject_any BEFORE DELETE ON preparation_users BEGIN SELECT RAISE(ABORT,'must not write'); END", + ); + final receipt = await workflow.save(await input(minutes: 5, spare: 10)); + expect(receipt.changed, isFalse); + expect(receipt.complete, isTrue); + expect(await profile(), before); + expect(await db.select(db.schedules).getSingle(), schedule); + expect(load.calls, 0); + expect(effects.calls, 0); + }); + test( + 'spare-only leaves preparation and dependent aggregate version intact', + () async { + final schedule = await db.select(db.schedules).getSingle(); + await db.customStatement( + "CREATE TRIGGER reject_any BEFORE DELETE ON preparation_users BEGIN SELECT RAISE(ABORT,'must not rewrite preparation'); END", + ); + await workflow.save(await input(minutes: 5)); + expect((await profile()).spareTime, 20); + expect(await db.select(db.schedules).getSingle(), schedule); + }, + ); + test( + 'default change invalidates dependents but preserves frozen owned history', + () async { + await db + .into(db.preparationDefinitions) + .insert( + PreparationDefinitionsCompanion.insert( + id: 'owned', + ownerId: 'frozen', + scope: 'schedule', + name: 'Frozen', + createdAt: DateTime.utc(2026), + ), + ); + await db + .into(db.preparationDefinitionSteps) + .insert( + PreparationDefinitionStepsCompanion.insert( + id: 'step', + definitionId: 'owned', + name: 'Original', + minutes: 12, + position: 0, + ), + ); + await db + .into(db.schedules) + .insert( + SchedulesCompanion.insert( + id: const Value('frozen'), + placeId: 'place', + scheduleName: 'History', + scheduleTime: DateTime.utc(2025), + moveTime: Duration.zero, + preparationDefinitionId: const Value('owned'), + preparationFrozen: const Value(true), + startedAt: Value(DateTime.utc(2025)), + finishedAt: Value(DateTime.utc(2025, 1, 1, 1)), + doneStatus: const Value('onTime'), + scoreContributionRecorded: const Value(true), + ), + ); + final before = await db.select(db.schedules).get(); + final owned = await db.select(db.preparationDefinitionSteps).get(); + await workflow.save(await input()); + final after = await db.select(db.schedules).get(); + expect( + after.firstWhere((s) => s.id == 'frozen'), + before.firstWhere((s) => s.id == 'frozen'), + ); + expect( + after.firstWhere((s) => s.id == 'dependent').aggregateVersion, + greaterThan( + before.firstWhere((s) => s.id == 'dependent').aggregateVersion!, + ), + ); + expect(await db.select(db.preparationDefinitionSteps).get(), owned); + }, + ); + test( + 'reload failure keeps commit and retries only reload, even using old receipt', + () async { + load.handler = () async => throw StateError('sensitive read failure'); + final command = await input(); + final receipt = await workflow.save(command); + final after = await profile(); + expect(receipt.reloadPending, isTrue); + expect(receipt.deliveryPending, isFalse); + expect(effects.calls, 1); + load.handler = null; + expect((await workflow.retry(receipt)).complete, isTrue); + expect((await workflow.retry(receipt)).complete, isTrue); + expect((await workflow.save(command)).complete, isTrue); + expect(await profile(), after); + expect(load.calls, 2); + expect(effects.calls, 1); + }, + ); + test( + 'delivery failure retries only delivery and cannot restore old preferences', + () async { + effects.handler = () async => false; + final receipt = await workflow.save(await input()); + await workflow.save(await input(minutes: 15, spare: 30)); + final after = await profile(); + effects.handler = () async { + expect((await profile()).spareTime, 30); + return true; + }; + expect((await workflow.retry(receipt)).complete, isTrue); + expect(await profile(), after); + expect(load.calls, 2); + }, + ); + test('duplicate submit and retry share actual pending Future', () async { + final release = Completer(); + load.handler = () => release.future; + final command = await input(); + final first = workflow.save(command); + final second = workflow.save(command); + expect(identical(first, second), isTrue); + release.complete(); + final receipt = await first; + expect(load.calls, 1); + expect(effects.calls, 1); + final after = await profile(); + await workflow.retry(receipt); + expect(await profile(), after); + }); + test( + 'restore generation after commit stops remaining effects and marks superseded', + () async { + load.handler = () => gate.run(() async {}, replacesData: true); + final receipt = await workflow.save(await input()); + final after = await profile(); + expect(receipt.changed, isTrue); + expect(receipt.superseded, isTrue); + expect(receipt.deliveryPending, isTrue); + expect(effects.calls, 0); + expect((await workflow.retry(receipt)).superseded, isTrue); + expect(await profile(), after); + }, + ); + for (final change in ['revision', 'store', 'generation', 'pending']) { + test( + '$change invalidates loaded baseline without mutation or effects', + () async { + final command = await input(); + if (change == 'revision') { + await db.userDao.markDurableDataChanged('local-profile'); + } + if (change == 'store') { + await db + .update(db.users) + .write( + const UsersCompanion(storeIncarnation: Value('different')), + ); + } + if (change == 'generation') { + await gate.run(() async {}, replacesData: true); + } + if (change == 'pending') gate.setRecoveryPending(true); + final before = await profile(); + await expectLater( + workflow.save(command), + throwsA(isA()), + ); + expect(await profile(), before); + expect(effects.calls, 0); + expect(load.calls, 0); + expect( + (await db.preparationUserDao.getPreparationUsersByUserId( + 'local-profile', + )).totalDuration.inMinutes, + 5, + ); + }, + ); + } + test( + 'stale identical values still conflict and ABA revision cannot be rebased', + () async { + final command = await input(minutes: 5, spare: 10); + await db.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 15), + ); + await db.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 10), + ); + await expectLater( + workflow.save(command), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + DefaultPreferencesFailure.conflict, + ), + ), + ); + }, + ); + for (final spare in [0, 3, 9, 1440]) { + test('valid $spare minute preference is preserved exactly', () async { + await workflow.save(await input(spare: spare)); + expect((await profile()).spareTime, spare); + }); + } + test('empty default is legal', () async { + final receipt = await workflow.save( + DefaultPreferencesSubmission( + baseline: await repo.read(), + preparation: const PreparationEntity(preparationStepList: []), + spareTime: Duration.zero, + ), + ); + expect(receipt.complete, isTrue); + expect((await repo.read()).preparation.preparationStepList, isEmpty); + }); + + for (final kind in [ + 'duplicate', + 'dangling', + 'cycle', + 'split', + 'name', + 'minutes', + 'subminute', + 'spare', + ]) { + test( + '$kind invalid input is rejected before any durable mutation', + () async { + final step = preparation().preparationStepList.single; + final steps = switch (kind) { + 'duplicate' => [step, step], + 'dangling' => [step.copyWith(nextPreparationId: 'missing')], + 'cycle' => [step.copyWith(nextPreparationId: 'step')], + 'split' => [ + step.copyWith(nextPreparationId: 'two'), + step.copyWith(id: 'two'), + step.copyWith(id: 'three'), + ], + 'name' => [step.copyWith(preparationName: ' ')], + 'minutes' => [step.copyWith(preparationTime: Duration.zero)], + 'subminute' => [ + step.copyWith(preparationTime: const Duration(seconds: 61)), + ], + _ => [step], + }; + final before = await profile(); + await expectLater( + workflow.save( + DefaultPreferencesSubmission( + baseline: await repo.read(), + preparation: PreparationEntity(preparationStepList: steps), + spareTime: kind == 'spare' + ? const Duration(minutes: 1441) + : const Duration(minutes: 20), + ), + ), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + DefaultPreferencesFailure.invalid, + ), + ), + ); + expect(await profile(), before); + expect((await repo.read()).preparation, preparation()); + expect(effects.calls, 0); + }, + ); + } + test( + 'omitted links use supplied list order; explicit split chains are invalid', + () async { + final first = preparation().preparationStepList.single; + await workflow.save( + DefaultPreferencesSubmission( + baseline: await repo.read(), + preparation: PreparationEntity( + preparationStepList: [ + first, + first.copyWith(id: 'two', preparationName: 'Second'), + ], + ), + spareTime: const Duration(minutes: 10), + ), + ); + final steps = (await repo.read()).preparation.preparationStepList; + expect(steps.map((s) => s.id), ['step', 'two']); + expect(steps.first.nextPreparationId, 'two'); + final before = await profile(); + await workflow.save( + DefaultPreferencesSubmission( + baseline: await repo.read(), + preparation: PreparationEntity( + preparationStepList: [ + first, + first.copyWith(id: 'two', preparationName: 'Second'), + ], + ), + spareTime: const Duration(minutes: 10), + ), + ); + expect(await profile(), before); + }, + ); + test( + 'a step ID owned by another profile rolls back the default replacement', + () async { + await db.userDao.putUser( + const UserEntity(id: 'other', spareTime: Duration.zero, note: ''), + ); + await db.preparationUserDao.createPreparationUser( + PreparationEntity( + preparationStepList: [ + preparation().preparationStepList.single.copyWith(id: 'foreign'), + ], + ), + 'other', + ); + final before = await profile(); + await expectLater( + workflow.save( + DefaultPreferencesSubmission( + baseline: await repo.read(), + preparation: PreparationEntity( + preparationStepList: [ + preparation().preparationStepList.single.copyWith( + id: 'foreign', + ), + ], + ), + spareTime: const Duration(minutes: 20), + ), + ), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + DefaultPreferencesFailure.failed, + ), + ), + ); + expect(await profile(), before); + expect((await repo.read()).preparation, preparation()); + expect( + (await db.preparationUserDao.getPreparationUsersByUserId( + 'other', + )).preparationStepList.single.id, + 'foreign', + ); + }, + ); + test( + 'actual Bloc preserves edits and reports saved reload-only pending', + () async { + load.handler = () async => throw StateError('not for UI'); + final bloc = DefaultPreparationSpareTimeFormBloc(workflow); + addTearDown(bloc.close); + bloc.add(const FormEditRequested()); + await bloc.stream.firstWhere( + (s) => s.status == DefaultPreparationSpareTimeStatus.success, + ); + bloc.add(const SpareTimeIncreased()); + await bloc.stream.firstWhere( + (s) => s.spareTime == const Duration(minutes: 15), + ); + bloc.add(FormSubmitted(preparation: preparation(8))); + await bloc.stream.firstWhere( + (s) => s.status == DefaultPreparationSpareTimeStatus.followUpPending, + ); + final after = await profile(); + expect(bloc.state.receipt!.reloadPending, isTrue); + expect(bloc.state.canSubmit, isFalse); + load.handler = null; + bloc.add(const FormFollowUpRetried()); + await bloc.stream.firstWhere( + (s) => s.status == DefaultPreparationSpareTimeStatus.submitted, + ); + expect(await profile(), after); + expect(effects.calls, 1); + }, + ); + for (final faultAt in [1, 3]) { + test( + 'postcommit authority SELECT $faultAt failure retains commit and retries without repeating successful effects', + () async { + final fault = _AuthorityFault(faultAt); + final other = AppDatabase.forTesting( + NativeDatabase.memory().interceptWith(fault), + ); + try { + await other.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 10), + note: '', + ), + ); + await other.preparationUserDao.createPreparationUser( + preparation(), + 'local-profile', + ); + final repository = DefaultPreferencesRepositoryImpl( + other, + gate: gate, + ); + final flow = DefaultPreferencesWorkflow(repository, load, effects); + final receipt = await flow.save( + DefaultPreferencesSubmission( + baseline: await repository.read(), + preparation: preparation(8), + spareTime: const Duration(minutes: 20), + ), + ); + expect(receipt.authorityPending, isTrue); + expect(receipt.superseded, isFalse); + expect(receipt.complete, isFalse); + expect(load.calls, faultAt == 1 ? 0 : 1); + expect(effects.calls, faultAt == 1 ? 0 : 1); + final after = await other.select(other.users).getSingle(); + expect(after.dataRevision, 1); + expect(after.spareTime, 20); + final retry = await flow.retry(receipt); + expect(retry.complete, isTrue); + expect(load.calls, 1); + expect(effects.calls, 1); + expect(await other.select(other.users).getSingle(), after); + } finally { + await other.close(); + } + }, + ); + } + test( + 'same-generation pending cleanup is unavailable and allows read-only follow-up retry', + () async { + load.handler = () async { + gate.setRecoveryPending(true); + }; + final receipt = await workflow.save(await input()); + final after = await profile(); + expect(receipt.authorityPending, isTrue); + expect(receipt.superseded, isFalse); + expect(effects.calls, 0); + gate.setRecoveryPending(false); + expect((await workflow.retry(receipt)).complete, isTrue); + expect(load.calls, 1); + expect(effects.calls, 1); + expect(await profile(), after); + }, + ); + test( + 'failed replacement still expires old operation without claiming replacement committed', + () async { + effects.handler = () async => false; + final receipt = await workflow.save(await input()); + final after = await profile(); + await expectLater( + gate.run(() async { + throw StateError('replacement transaction rolled back'); + }, replacesData: true), + throwsStateError, + ); + final retry = await workflow.retry(receipt); + expect(retry.superseded, isTrue); + expect(retry.authorityPending, isFalse); + expect(load.calls, 1); + expect(effects.calls, 1); + expect(await profile(), after); + }, + ); + test( + 'coherent read excludes concurrent edit between profile and preparation reads', + () async { + final barrier = _Barrier(); + final other = AppDatabase.forTesting( + NativeDatabase.memory().interceptWith(barrier), + ); + try { + await other.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 10), + note: '', + ), + ); + await other.preparationUserDao.createPreparationUser( + preparation(), + 'local-profile', + ); + final reader = DefaultPreferencesRepositoryImpl(other, gate: gate); + barrier.armed = true; + final reading = reader.read(); + await barrier.entered.future; + final writing = other.writeTransaction(() async { + await other.preparationUserDao.createPreparationUser( + preparation(12), + 'local-profile', + ); + await other.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 30), + ); + }, gate: gate); + barrier.release.complete(); + final snapshot = await reading; + await writing; + expect(snapshot.spareTime, const Duration(minutes: 10)); + expect(snapshot.preparation.totalDuration.inMinutes, 5); + expect(snapshot.revision, 0); + expect((await reader.read()).spareTime, const Duration(minutes: 30)); + } finally { + await other.close(); + } + }, + ); + test( + 'generation change during a delayed read rejects its old snapshot', + () async { + final barrier = _Barrier(); + final other = AppDatabase.forTesting( + NativeDatabase.memory().interceptWith(barrier), + ); + try { + await other.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final reader = DefaultPreferencesRepositoryImpl(other, gate: gate); + barrier.armed = true; + final reading = reader.read(); + final failure = expectLater( + reading, + throwsA(isA()), + ); + await barrier.entered.future; + await gate.run(() async {}, replacesData: true); + barrier.release.complete(); + await failure; + } finally { + await other.close(); + } + }, + ); +} diff --git a/test/data/repositories/first_start_time_capture_test.dart b/test/data/repositories/first_start_time_capture_test.dart new file mode 100644 index 00000000..b4d8770e --- /dev/null +++ b/test/data/repositories/first_start_time_capture_test.dart @@ -0,0 +1,361 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + late Directory directory; + late AppDatabase database; + late ScheduleRepositoryImpl repository; + late DateTime now; + late Map originalRules; + late tz.Location originalLocal; + + Future open() async { + database = AppDatabase.forTesting( + NativeDatabase(File('${directory.path}/app.sqlite')), + ); + repository = ScheduleRepositoryImpl( + database: database, + timedPreparationRepository: _UnusedRuntime(), + now: () => now, + ); + } + + setUp(() async { + TimeZoneRules.ensureInitialized(); + originalRules = Map.of(tz.timeZoneDatabase.locations); + originalLocal = tz.local; + now = DateTime.utc(2026, 9, 24); + directory = await Directory.systemTemp.createTemp('ontime-a10-start-'); + await open(); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + }); + + tearDown(() async { + await repository.dispose(); + await database.close(); + await directory.delete(recursive: true); + tz.timeZoneDatabase.locations + ..clear() + ..addAll(originalRules); + tz.setLocalLocation(originalLocal); + }); + + ScheduleEntity schedule({ + String id = 'appointment', + DateTime? civil, + String zone = 'Asia/Seoul', + int? offset, + }) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Office'), + scheduleName: 'Appointment', + scheduleTime: civil ?? DateTime.utc(2026, 9, 25, 9, 0, 4, 123, 456), + timeZoneId: zone, + occurrenceOffsetSeconds: offset, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Retain the original civil fields', + ); + + Future> row([String id = 'appointment']) async => + (await database.customSelect('SELECT * FROM schedules').get()) + .singleWhere((value) => value.read('id') == id) + .data; + + Future> profile() async => + (await database.customSelect('SELECT * FROM users').getSingle()).data; + + ScheduleWithPreparationEntity runtime(ScheduleEntity value) => + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + value, + const PreparationWithTimeEntity(preparationStepList: []), + timeResolution: ScheduleTimeResolver.resolve( + value, + nowUtc: now.toUtc(), + ), + ); + + test( + 'only first start records the rule-derived offset with facts and one revision', + () async { + await repository.createSchedule(schedule()); + final before = await row(); + final beforeProfile = await profile(); + final raw = await repository.getScheduleById('appointment'); + final interpreted = runtime(raw); + final fingerprint = interpreted.cacheFingerprint; + expect( + interpreted.occurrenceInstantUtc, + DateTime.utc(2026, 9, 25, 0, 0, 4, 123, 456), + ); + expect(raw.occurrenceOffsetSeconds, isNull); + expect( + await row(), + before, + reason: 'Read and interpretation cannot backfill the offset', + ); + expect(await profile(), beforeProfile); + + final firstStart = now.add(const Duration(seconds: 7)); + expect( + await repository.startSchedule('appointment', startedAt: firstStart), + firstStart, + ); + final after = await row(); + final started = await repository.getScheduleById('appointment'); + expect(started.occurrenceOffsetSeconds, 32400); + expect(started.startedAt!.toUtc(), firstStart); + expect(started.isStarted, isTrue); + expect(started.preparationFrozen, isTrue); + expect(after['schedule_time'], before['schedule_time']); + expect(after['aggregate_incarnation'], before['aggregate_incarnation']); + expect( + after['aggregate_version'], + (before['aggregate_version'] as int) + 1, + ); + expect( + (await profile())['data_revision'], + (beforeProfile['data_revision'] as int) + 1, + ); + expect(runtime(started).cacheFingerprint, fingerprint); + }, + ); + + test( + 'revision failure rolls back offset, facts, aggregate authority and all profile markers', + () async { + await repository.createSchedule(schedule()); + final before = await row(); + final beforeProfile = await profile(); + await database.customStatement( + "CREATE TRIGGER reject_a10_revision BEFORE UPDATE OF data_revision ON users BEGIN SELECT RAISE(ABORT, 'a10 injected revision failure'); END", + ); + await expectLater( + repository.startSchedule('appointment', startedAt: now), + throwsA( + predicate( + (error) => + error.toString().contains('a10 injected revision failure'), + ), + ), + ); + expect(await row(), before); + expect(await profile(), beforeProfile); + await database.customStatement('DROP TRIGGER reject_a10_revision'); + + final first = await repository.startSchedule( + 'appointment', + startedAt: now, + ); + final committed = await row(); + final committedProfile = await profile(); + now = now.add(const Duration(minutes: 5)); + expect( + await repository.startSchedule('appointment', startedAt: now), + first, + ); + expect(await row(), committed); + expect(await profile(), committedProfile); + expect(committed['occurrence_offset_seconds'], 32400); + expect( + committedProfile['data_revision'], + (beforeProfile['data_revision'] as int) + 1, + ); + }, + ); + + test( + 'concurrent and repeated starts retain one offset and first start receipt', + () async { + await repository.createSchedule(schedule()); + final before = await row(); + final beforeProfile = await profile(); + final requested = [now, now.add(const Duration(seconds: 1))]; + final results = await Future.wait([ + for (final stamp in requested) + repository.startSchedule('appointment', startedAt: stamp), + ]); + expect(results[0], results[1]); + expect(requested, contains(results.first)); + final committed = await row(); + final committedProfile = await profile(); + expect(committed['occurrence_offset_seconds'], 32400); + expect( + committed['aggregate_version'], + (before['aggregate_version'] as int) + 1, + ); + expect( + committedProfile['data_revision'], + (beforeProfile['data_revision'] as int) + 1, + ); + expect( + await repository.startSchedule( + 'appointment', + startedAt: now.add(const Duration(hours: 1)), + ), + results.first, + ); + expect(await row(), committed); + expect(await profile(), committedProfile); + }, + ); + + test( + 'an explicitly selected past occurrence still permits an otherwise valid manual late start', + () async { + await repository.createSchedule( + schedule(civil: DateTime.utc(2026, 9, 23, 9), offset: 32400), + ); + expect( + await repository.startSchedule('appointment', startedAt: now), + now, + ); + final started = await repository.getScheduleById('appointment'); + expect(started.occurrenceOffsetSeconds, 32400); + expect(started.occurrenceInstantUtc, DateTime.utc(2026, 9, 23)); + expect(started.isStarted, isTrue); + }, + ); + + for (final kind in [ + 'past-null', + 'overlap-null', + 'gap', + 'unknown-zone', + 'stale-offset', + 'restored-protected-null', + ]) { + test('$kind cannot acquire new start facts or a guessed offset', () async { + now = DateTime.utc(2026, 1, 1); + final value = switch (kind) { + 'past-null' => schedule(civil: DateTime.utc(2025, 12, 31, 9)), + 'overlap-null' => schedule( + civil: DateTime.utc(2026, 11, 1, 1, 30), + zone: 'America/New_York', + ), + 'gap' => schedule( + civil: DateTime.utc(2026, 3, 8, 2, 30), + zone: 'America/New_York', + ), + 'unknown-zone' => schedule(zone: 'Not/A_Real_Zone'), + 'stale-offset' => schedule(offset: 36000), + 'restored-protected-null' => schedule().copyWith( + startedAt: DateTime.utc(2025), + preparationFrozen: true, + ), + _ => throw StateError('Unknown test case'), + }; + // The existing repository writer seeds an admitted legacy row; this test + // exercises start admission, not new-form save validation or a restore. + await repository.createSchedule(value); + final before = await row(); + final beforeProfile = await profile(); + await expectLater( + repository.startSchedule('appointment', startedAt: now), + throwsA(isA()), + ); + expect(await row(), before); + expect(await profile(), beforeProfile); + }); + } + + test( + 'an already active protected null row is not silently repaired by an idempotent retry', + () async { + final originalStart = DateTime.utc(2026, 9, 23); + await repository.createSchedule( + schedule().copyWith( + isStarted: true, + startedAt: originalStart, + preparationFrozen: true, + ), + ); + final before = await row(); + final beforeProfile = await profile(); + expect( + await repository.startSchedule('appointment', startedAt: now), + originalStart, + ); + expect(await row(), before); + expect(await profile(), beforeProfile); + expect( + (await repository.getScheduleById( + 'appointment', + )).occurrenceOffsetSeconds, + isNull, + ); + }, + ); + + test( + 'the captured target survives file-database reopen, deadline and loaded rule changes', + () async { + const zone = 'Test/A10_Start_Target'; + tz.Location fixedRule(int offset) => tz.Location(zone, [], [], [ + tz.TimeZone(offset, isDst: false, abbreviation: 'A10'), + ]); + tz.timeZoneDatabase.locations[zone] = fixedRule(32400000); + await repository.createSchedule(schedule(zone: zone)); + final prior = runtime(await repository.getScheduleById('appointment')); + final instant = prior.occurrenceInstantUtc; + final fingerprint = prior.cacheFingerprint; + final first = await repository.startSchedule( + 'appointment', + startedAt: now, + ); + final committed = await row(); + final committedProfile = await profile(); + + await repository.dispose(); + await database.close(); + now = DateTime.utc(2026, 10, 1); + tz.timeZoneDatabase.locations[zone] = fixedRule(36000000); + // This changes the timezone library's display location; actual OS timezone + // behavior is covered separately by the explicit process TZ matrix. + tz.setLocalLocation(TimeZoneRules.location('America/New_York')); + await open(); + final reopened = await repository.getScheduleById('appointment'); + final resolution = ScheduleTimeResolver.resolve(reopened, nowUtc: now); + expect(resolution.isHistorical, isTrue); + expect(resolution.instantUtc, instant); + expect(runtime(reopened).cacheFingerprint, fingerprint); + expect( + await repository.startSchedule('appointment', startedAt: now), + first, + ); + expect(await row(), committed); + expect(await profile(), committedProfile); + }, + ); +} + +/// Starting the durable schedule is independent of runtime projection storage. +class _UnusedRuntime implements TimedPreparationRepository { + @override + dynamic noSuchMethod(Invocation invocation) => throw StateError( + 'Unexpected runtime projection access: ${invocation.memberName}', + ); +} diff --git a/test/data/repositories/nearest_automatic_start_guard_test.dart b/test/data/repositories/nearest_automatic_start_guard_test.dart new file mode 100644 index 00000000..5acf1b61 --- /dev/null +++ b/test/data/repositories/nearest_automatic_start_guard_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/early_start_session_repository.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import '../../helpers/noop_alarm_reconciliation.dart'; + +class _Runtime extends Fake implements TimedPreparationRepository {} + +class _Preparation extends Fake implements PreparationRepository {} + +class _Early extends Fake implements EarlyStartSessionRepository {} + +class _Cancel extends Fake implements CancelScheduleAlarmUseCase {} + +// The real SQLite update has executed, but the surrounding transaction has not +// returned yet. Invalidating here tests rollback after all durable writes. +class _AfterRevision extends QueryInterceptor { + bool armed = false; + final entered = Completer(); + final release = Completer(); + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final value = await executor.runUpdate(statement, args); + if (armed && statement.contains('data_revision')) { + armed = false; + entered.complete(); + await release.future; + } + return value; + } +} + +PreparationEntity _preparation([int minutes = 10]) => PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prepare', + preparationName: 'Synthetic preparation', + preparationTime: Duration(minutes: minutes), + ), + ], +); + +class _Fixture { + _Fixture({this.barrier}); + final _AfterRevision? barrier; + late AppDatabase db; + late ScheduleRepositoryImpl repository; + late SchedulePreparationSessionUseCase session; + final operations = AlarmOperationCoordinator(LocalDataOperationGate.shared); + final alarms = NoopAlarmReconciliation(); + final now = DateTime.utc(2029, 1, 1, 12); + + Future open() async { + final native = NativeDatabase.memory(); + db = AppDatabase.forTesting( + barrier == null ? native : native.interceptWith(barrier!), + ); + await db.userDao.putUser( + const UserEntity(id: localProfileId, spareTime: Duration.zero, note: ''), + ); + final runtime = _Runtime(); + repository = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: runtime, + now: () => now, + ); + await repository.createSchedule( + ScheduleEntity( + id: 'appointment', + place: const PlaceEntity(id: 'place', placeName: 'Synthetic place'), + scheduleName: 'Synthetic appointment', + scheduleTime: DateTime.utc(2029, 1, 1, 12, 10), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: true, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparationMode: SchedulePreparationMode.custom, + ), + ); + await db.preparationScheduleDao.createPreparationSchedule( + _preparation(), + 'appointment', + ); + session = SchedulePreparationSessionUseCase( + repository, + _Preparation(), + runtime, + _Early(), + _Cancel(), + alarms, + operations: operations, + ); + } + + Future revision() async => + (await db.select(db.users).getSingle()).dataRevision; + Future row() => repository.getScheduleById('appointment'); + Future fingerprint({int preparationMinutes = 10}) async => + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + await row(), + PreparationWithTimeEntity.fromPreparation( + _preparation(preparationMinutes), + ), + timeResolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: DateTime.utc(2029, 1, 1, 12, 10), + ), + ).cacheFingerprint; + Future close() async { + session.dispose(); + operations.dispose(); + await repository.dispose(); + await db.close(); + } +} + +void main() { + test( + 'actual queued start checks intent after the occupied alarm queue is released', + () async { + final f = _Fixture(); + await f.open(); + addTearDown(f.close); + final original = await f.row(); + final before = await f.revision(); + final fingerprint = await f.fingerprint(); + var current = true; + final entered = Completer(); + final release = Completer(); + final blocker = f.operations.run(f.operations.capture(), () async { + entered.complete(); + await release.future; + }); + addTearDown(() { + if (!release.isCompleted) release.complete(); + }); + await entered.future; + final pending = f.session.startSchedulePreparation( + 'appointment', + isCurrent: () => current, + expectedFingerprint: fingerprint, + ); + final failure = expectLater( + pending, + throwsA(isA()), + ); + await Future.delayed(Duration.zero); + current = false; + release.complete(); + await blocker; + await failure; + expect(await f.row(), original); + expect(await f.revision(), before); + expect(f.alarms.callCount, 0); + current = true; + await f.session.startSchedulePreparation( + 'appointment', + isCurrent: () => current, + expectedFingerprint: fingerprint, + ); + expect((await f.row()).isStarted, isTrue); + expect((await f.row()).startedAt!.toUtc(), f.now); + expect(await f.revision(), before + 1); + }, + ); + + test( + 'same-schedule queued replacement keeps its own guard and committed receipt', + () async { + final f = _Fixture(); + await f.open(); + addTearDown(f.close); + final before = await f.revision(); + final fingerprint = await f.fingerprint(); + final entered = Completer(); + final release = Completer(); + final blocker = f.operations.run(f.operations.capture(), () async { + entered.complete(); + await release.future; + }); + addTearDown(() { + if (!release.isCompleted) { + release.complete(); + } + }); + await entered.future; + var oldCurrent = true; + final old = f.session.startSchedulePreparation( + 'appointment', + isCurrent: () => oldCurrent, + expectedFingerprint: fingerprint, + ); + final oldRejected = expectLater( + old, + throwsA(isA()), + ); + oldCurrent = false; + final replacement = f.session.startSchedulePreparation( + 'appointment', + isCurrent: () => true, + expectedFingerprint: fingerprint, + ); + release.complete(); + await blocker; + await oldRejected; + final receipt = await replacement; + expect(receipt.toUtc(), f.now); + expect((await f.row()).startedAt!.toUtc(), receipt.toUtc()); + expect(await f.revision(), before + 1); + expect(f.alarms.callCount, 0); + }, + ); + + test( + 'intent invalidation after actual SQL revision update rolls back the entire start and fresh retry commits once', + () async { + final barrier = _AfterRevision(); + final f = _Fixture(barrier: barrier); + await f.open(); + addTearDown(f.close); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + }); + final original = await f.row(); + final before = await f.revision(); + final fingerprint = await f.fingerprint(); + var current = true; + barrier.armed = true; + final pending = f.repository.startSchedule( + 'appointment', + isCurrent: () => current, + expectedFingerprint: fingerprint, + ); + final failure = expectLater( + pending, + throwsA(isA()), + ); + await barrier.entered.future.timeout(const Duration(seconds: 5)); + current = false; + barrier.release.complete(); + await failure; + expect(await f.row(), original); + expect((await f.row()).startedAt, isNull); + expect((await f.row()).preparationFrozen, isFalse); + expect(await f.revision(), before); + current = true; + await f.repository.startSchedule( + 'appointment', + isCurrent: () => current, + expectedFingerprint: fingerprint, + ); + final started = await f.row(); + expect(started.isStarted, isTrue); + expect(started.preparationFrozen, isTrue); + expect(started.startedAt!.toUtc(), f.now); + expect(await f.revision(), before + 1); + }, + ); + + for (final preparationOnly in [true, false]) { + test( + 'actual ${preparationOnly ? 'preparation-only' : 'travel-time'} change rejects an old fingerprint even while the caller still considers its intent current', + () async { + final f = _Fixture(); + await f.open(); + addTearDown(f.close); + final fingerprint = await f.fingerprint(); + if (preparationOnly) { + await f.db.preparationScheduleDao.createPreparationSchedule( + _preparation(11), + 'appointment', + ); + } else { + await f.repository.updateSchedule( + (await f.row()).copyWith(moveTime: const Duration(minutes: 1)), + ); + } + final original = await f.row(); + final before = await f.revision(); + await expectLater( + f.repository.startSchedule( + 'appointment', + isCurrent: () => true, + expectedFingerprint: fingerprint, + ), + throwsA(isA()), + ); + expect(await f.row(), original); + expect(await f.revision(), before); + final fresh = await f.fingerprint( + preparationMinutes: preparationOnly ? 11 : 10, + ); + expect(fresh, isNot(fingerprint)); + await f.repository.startSchedule( + 'appointment', + isCurrent: () => true, + expectedFingerprint: fresh, + ); + expect((await f.row()).isStarted, isTrue); + expect(await f.revision(), before + 1); + }, + ); + } +} diff --git a/test/data/repositories/nearest_query_sql_boundaries_test.dart b/test/data/repositories/nearest_query_sql_boundaries_test.dart new file mode 100644 index 00000000..944ac307 --- /dev/null +++ b/test/data/repositories/nearest_query_sql_boundaries_test.dart @@ -0,0 +1,302 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/nearest_schedule_query_repository_impl.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; + +const _prep = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prepare', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], +); +ScheduleEntity _schedule(String id, DateTime time) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Synthetic place'), + scheduleName: id, + scheduleTime: time, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); +NearestQueryKey _key() => NearestQueryKey( + generation: LocalDataOperationGate.shared.generation, + epoch: 1, + revision: 0, +); +Future _finish( + NearestScheduleQuerySession session, +) async { + while (true) { + final result = await session.advance(candidateBudget: 1024); + if (result is! NearestQueryLimited || !result.canContinue) { + return result; + } + } +} + +class _SelectedPreparationFailure extends QueryInterceptor { + bool armed = false; + int injected = 0; + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + // Snapshot SELECT * is deliberately unaffected; fail only the selected + // definition's real one-shot ordered preparation query after materialization. + if (armed && + statement.contains('preparation_definition_steps') && + statement.contains('WHERE') && + statement.contains('ORDER BY')) { + armed = false; + injected++; + throw StateError('Synthetic selected preparation SQL read failure'); + } + return executor.runSelect(statement, args); + } +} + +// Transparent observation around real SQLite query sessions. It never replaces +// candidates, results, database writes, cancellation or the source change stream. +class _MeasuredRepository implements NearestScheduleQueryRepository { + _MeasuredRepository(this.inner); + final NearestScheduleQueryRepository inner; + int heartbeat = 0; + int opens = 0; + final chunkMicros = []; + final chunkSynchronousMicros = []; + final heartbeatAtEntry = []; + @override + Stream get changes => inner.changes; + @override + Future readActive() => inner.readActive(); + @override + Future open(NearestQueryKey key) async { + opens++; + return _MeasuredSession(await inner.open(key), this); + } +} + +class _MeasuredSession implements NearestScheduleQuerySession { + _MeasuredSession(this.inner, this.owner); + final NearestScheduleQuerySession inner; + final _MeasuredRepository owner; + @override + void cancel() => inner.cancel(); + @override + Future advance({required int candidateBudget}) async { + owner.heartbeatAtEntry.add(owner.heartbeat); + final watch = Stopwatch()..start(); + try { + final pending = inner.advance(candidateBudget: candidateBudget); + owner.chunkSynchronousMicros.add(watch.elapsedMicroseconds); + return await pending; + } finally { + owner.chunkMicros.add(watch.elapsedMicroseconds); + Timer.run(() => owner.heartbeat++); + } + } +} + +void main() { + test( + 'selected preparation SQL exception rolls back materialization instead of publishing later appointment or Empty', + () async { + final fault = _SelectedPreparationFailure(); + final db = AppDatabase.forTesting( + NativeDatabase.memory().interceptWith(fault), + ); + addTearDown(db.close); + final now = DateTime.utc(2030, 1, 1, 12); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + final first = now.add(const Duration(days: 1)); + await recurring.create( + _schedule('series', first), + _prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 2, + ), + ); + await db.delete(db.schedules).go(); + await db.scheduleDao.createSchedule( + _schedule( + 'later', + now.add(const Duration(days: 20)), + ).toScheduleWithPlaceRow(), + ); + final before = (await db.select(db.users).getSingle()).dataRevision; + final repository = NearestScheduleQueryRepositoryImpl( + db, + recurring, + now: () => now, + ); + final session = await repository.open(_key()); + fault.armed = true; + final result = await _finish(session); + expect( + fault.injected, + 1, + reason: + 'The real selected preparation query must reach the injected SQL boundary.', + ); + expect( + result, + isA().having( + (v) => v.reason, + 'selected SQL read failure', + NearestQueryFailureReason.preparationReadFailed, + ), + ); + expect( + (await db.scheduleDao.getScheduleList()).map((r) => r.schedule.id), + ['later'], + ); + expect((await db.select(db.users).getSingle()).dataRevision, before); + final retry = + await _finish(await repository.open(_key())) as NearestQueryReady; + expect(retry.value.resolution.instantUtc, first); + expect( + retry + .value + .schedule + .preparation + .preparationStepList + .single + .preparationName, + 'Pack', + ); + expect(await db.scheduleDao.getScheduleList(), hasLength(2)); + }, + ); + + for (final workload in [(1, false), (8, false), (32, true)]) { + final segments = workload.$1; + final monthly = workload.$2; + test( + 'three-year ${monthly ? "monthly day-31 skips" : "daily history"} with $segments segments automatically yields bounded chunks before a real ready result', + () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + var now = DateTime.utc(2027, 1, 1, 0); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + for (var index = 0; index < segments; index++) { + final first = DateTime.utc(2027, 1, monthly ? 31 : 1, 12, index * 10); + await recurring.create( + _schedule('series-$index', first), + _prep, + RecurrenceRule( + frequency: monthly + ? RecurrenceFrequency.monthly + : RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 2000, + ), + ); + } + await db.delete(db.schedules).go(); + now = DateTime.utc(2030, 1, 1, 11); + final measured = _MeasuredRepository( + NearestScheduleQueryRepositoryImpl(db, recurring, now: () => now), + ); + final ready = Completer(); + final seen = []; + final initialRss = ProcessInfo.currentRss; + final watch = Stopwatch()..start(); + final sub = + GetNearestUpcomingScheduleUseCase(measured, now: () => now)( + key: _key(), + ).listen((event) { + seen.add(event); + if (event is NearestQueryReady && !ready.isCompleted) { + ready.complete(event); + } + }, onError: ready.completeError); + try { + final result = await ready.future.timeout( + const Duration(seconds: 20), + ); + final firstReadyMicros = watch.elapsedMicroseconds; + expect( + result.value.resolution.instantUtc, + DateTime.utc(2030, 1, monthly ? 31 : 1, 12), + ); + expect( + seen.whereType(), + isEmpty, + reason: + 'Ordinary chunks automatically progress without user Continue clicks.', + ); + expect( + seen.whereType().any( + (v) => v.progress.visitedCandidates >= 1024, + ), + isTrue, + ); + expect(measured.chunkMicros.length, greaterThan(1)); + expect( + measured.heartbeatAtEntry.skip(1).any((v) => v > 0), + isTrue, + reason: + 'An event-loop task must run between automatic real query chunks.', + ); + expect( + await db.scheduleDao.getScheduleList(), + hasLength(1), + reason: 'Only the global winner is materialized.', + ); + // Host characterization only. No arbitrary timing threshold or native + // frame-rate guarantee is inferred from these measurements. + // ignore: avoid_print + print( + 'C08_QUERY_CHARACTERIZATION ${jsonEncode({'segments': segments, 'historyYears': 3, 'monthlyDay31': monthly, 'chunkBudget': 1024, 'queryOpens': measured.opens, 'chunkMicroseconds': measured.chunkMicros, 'synchronousCallMicroseconds': measured.chunkSynchronousMicros, 'firstReadyMicroseconds': firstReadyMicros, 'eventLoopHeartbeats': measured.heartbeat, 'initialRssBytes': initialRss, 'finalRssBytes': ProcessInfo.currentRss})}', + ); + } finally { + await sub.cancel(); + } + }, + ); + } +} diff --git a/test/data/repositories/nearest_schedule_query_repository_test.dart b/test/data/repositories/nearest_schedule_query_repository_test.dart new file mode 100644 index 00000000..84c61466 --- /dev/null +++ b/test/data/repositories/nearest_schedule_query_repository_test.dart @@ -0,0 +1,559 @@ +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'dart:async'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/nearest_schedule_query_repository_impl.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; + +// Transparent work counters; all answers still come from production SQLite. +class _ObservedQueryRepository implements NearestScheduleQueryRepository { + _ObservedQueryRepository(this.delegate); + final NearestScheduleQueryRepository delegate; + int inFlight = 0; + int opens = 0; + int changesSeen = 0; + @override + Stream get changes => delegate.changes.map((_) { + changesSeen++; + }); + @override + Future open(NearestQueryKey key) async { + opens++; + inFlight++; + try { + return _ObservedQuerySession(await delegate.open(key), this); + } finally { + inFlight--; + } + } + + @override + Future readActive() => delegate.readActive(); +} + +class _ObservedQuerySession implements NearestScheduleQuerySession { + _ObservedQuerySession(this.delegate, this.observer); + final NearestScheduleQuerySession delegate; + final _ObservedQueryRepository observer; + @override + Future advance({required int candidateBudget}) async { + observer.inFlight++; + try { + return await delegate.advance(candidateBudget: candidateBudget); + } finally { + observer.inFlight--; + } + } + + @override + void cancel() => delegate.cancel(); +} + +void main() { + late AppDatabase db; + late RecurringScheduleRepositoryImpl recurring; + late NearestScheduleQueryRepositoryImpl repository; + late DateTime now; + ScheduleEntity schedule(String id, DateTime time) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Office'), + scheduleName: id, + scheduleTime: time, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + const prep = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); + NearestQueryKey key() => NearestQueryKey( + generation: LocalDataOperationGate.shared.generation, + epoch: 1, + revision: 0, + ); + Future finish( + NearestScheduleQuerySession session, { + int chunk = 16, + }) async { + while (true) { + final result = await session.advance(candidateBudget: chunk); + if (result is! NearestQueryLimited || !result.canContinue) return result; + } + } + + Future insert(ScheduleEntity value) => db.scheduleDao + .createSchedule(value.toScheduleWithPlaceRow()) + .then((_) {}); + setUp(() async { + now = DateTime.utc(2030, 1, 1, 12); + db = AppDatabase.forTesting(NativeDatabase.memory()); + recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + repository = NearestScheduleQueryRepositoryImpl( + db, + recurring, + now: () => now, + ); + await db.userDao.putUser( + const UserEntity(id: 'local-profile', spareTime: Duration.zero, note: ''), + ); + }); + tearDown(() => db.close()); + + test( + 'global future selection has no 48-hour cutoff, skips ended, ties by stable ID', + () async { + final time = now.add(const Duration(days: 30)); + await insert(schedule('z', time)); + await insert(schedule('a', time)); + await insert( + schedule( + 'ended', + now.add(const Duration(hours: 1)), + ).copyWith(doneStatus: ScheduleDoneStatus.normalEnd), + ); + await insert(schedule('past', now.subtract(const Duration(hours: 1)))); + final result = + await finish(await repository.open(key())) as NearestQueryReady; + expect(result.value.schedule.id, 'a'); + expect(result.value.schedule.preparation.preparationStepList, isEmpty); + expect((await db.scheduleDao.getScheduleList()), hasLength(4)); + }, + ); + + test( + 'unmaterialized nearer recurrence beats stored next week and only winner is materialized', + () async { + final first = now.add(const Duration(days: 1)); + await recurring.create( + schedule('series', first), + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 10, + ), + ); + // Remove cache rows only, retaining canonical rule/count/exclusions. + await db.delete(db.schedules).go(); + await insert(schedule('next-week', now.add(const Duration(days: 7)))); + final session = await repository.open(key()); + final partial = await session.advance(candidateBudget: 1); + expect( + partial, + isA(), + reason: 'The next unvisited civil frontier still needs proof.', + ); + final result = await finish(session) as NearestQueryReady; + expect(result.value.resolution.instantUtc, first); + expect( + result + .value + .schedule + .preparation + .preparationStepList + .single + .preparationName, + 'Pack', + ); + expect(await db.scheduleDao.getScheduleList(), hasLength(2)); + }, + ); + + test( + 'small total budget stays terminal limited despite stored candidate', + () async { + final first = now.add(const Duration(days: 1)); + await recurring.create( + schedule('series', first), + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 5, + ), + ); + await db.delete(db.schedules).go(); + await insert(schedule('stored', now.add(const Duration(days: 20)))); + final bounded = NearestScheduleQueryRepositoryImpl( + db, + recurring, + now: () => now, + totalBudget: 1, + ); + final session = await bounded.open(key()); + final result = + await session.advance(candidateBudget: 1024) as NearestQueryLimited; + expect(result.reason, NearestQueryLimitReason.searchBudgetExhausted); + expect(result.canContinue, isFalse); + expect(result.canRetry, isFalse); + expect( + (await session.advance(candidateBudget: 1024) as NearestQueryLimited) + .progress + .visitedCandidates, + 1, + ); + expect(await db.scheduleDao.getScheduleList(), hasLength(1)); + }, + ); + + test( + 'snapshot change invalidates old search authority before publishing', + () async { + await insert(schedule('later', now.add(const Duration(days: 8)))); + final session = await repository.open(key()); + await insert(schedule('earlier', now.add(const Duration(days: 3)))); + await expectLater( + session.advance(candidateBudget: 16), + throwsA(isA()), + ); + final latest = + await finish(await repository.open(key())) as NearestQueryReady; + expect(latest.value.schedule.id, 'earlier'); + }, + ); + + test( + 'durable active is read separately and future recommendation cannot replace it', + () async { + final active = + schedule('active', now.subtract(const Duration(minutes: 1))).copyWith( + isStarted: true, + startedAt: now.subtract(const Duration(minutes: 5)), + preparationFrozen: true, + ); + await insert(active); + await insert(schedule('future', now.add(const Duration(days: 3)))); + expect((await repository.readActive())!.id, 'active'); + expect( + (await finish(await repository.open(key())) as NearestQueryReady) + .value + .schedule + .id, + 'future', + ); + expect( + (await db.scheduleDao.getScheduleById( + 'active', + )).toScheduleEntity().doneStatus, + ScheduleDoneStatus.notEnded, + ); + }, + ); + + test( + 'preparation-only SQLite change refreshes the selected preparation', + () async { + final first = now.add(const Duration(days: 1)); + await recurring.create( + schedule('series', first), + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 1, + ), + ); + final seen = []; + final firstReady = Completer(); + final updatedReady = Completer(); + final subscription = + GetNearestUpcomingScheduleUseCase(repository, now: () => now)( + key: key(), + ).listen((query) { + seen.add(query); + if (query is NearestQueryReady) { + final name = query + .value + .schedule + .preparation + .preparationStepList + .single + .preparationName; + if (!firstReady.isCompleted) firstReady.complete(); + if (name == 'Updated' && !updatedReady.isCompleted) { + updatedReady.complete(); + } + } + }); + try { + await firstReady.future.timeout(const Duration(seconds: 5)); + await db + .update(db.preparationDefinitionSteps) + .write( + const PreparationDefinitionStepsCompanion(name: Value('Updated')), + ); + await updatedReady.future.timeout(const Duration(seconds: 5)); + expect( + seen + .whereType() + .last + .value + .schedule + .preparation + .preparationStepList + .single + .preparationName, + 'Updated', + ); + } finally { + await subscription.cancel(); + } + }, + ); + test( + 'different civil zones are ranked by instant, while unknown-only is a typed safe result', + () async { + await insert( + schedule( + 'tokyo', + DateTime.utc(2030, 1, 2, 9), + ).copyWith(timeZoneId: 'Asia/Tokyo', occurrenceOffsetSeconds: 9 * 3600), + ); + await insert(schedule('utc', DateTime.utc(2030, 1, 2, 1))); + await insert( + schedule( + 'unknown', + DateTime.utc(2030, 1, 2), + ).copyWith(timeZoneId: 'Unknown/Zone'), + ); + final result = + await finish(await repository.open(key())) as NearestQueryReady; + expect(result.value.schedule.id, 'tokyo'); + expect(result.value.resolution.instantUtc, DateTime.utc(2030, 1, 2)); + expect(result.issues.single.scheduleId, 'unknown'); + await (db.delete( + db.schedules, + )..where((row) => row.id.isNotValue('unknown'))).go(); + final unresolved = await finish(await repository.open(key())); + expect(unresolved, isA()); + expect( + (unresolved as NearestQueryError).issues.single.reason, + NearestQueryIssueReason.unknownTimeZone, + ); + }, + ); + + test( + 'missing selected preparation rolls back winner materialization and never chooses later row', + () async { + final first = now.add(const Duration(days: 1)); + await recurring.create( + schedule('series', first), + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 1, + ), + ); + await db.delete(db.schedules).go(); + // Explicit corrupt-store fixture: preserve the dangling segment reference. + await db.customStatement('PRAGMA foreign_keys = OFF'); + await db.delete(db.preparationDefinitionSteps).go(); + await db.delete(db.preparationDefinitions).go(); + await db.customStatement('PRAGMA foreign_keys = ON'); + await insert(schedule('later', now.add(const Duration(days: 20)))); + final result = await finish(await repository.open(key())); + expect( + result, + isA().having( + (v) => v.reason, + 'missing is not empty/fallback', + NearestQueryFailureReason.preparationMissing, + ), + ); + expect( + (await db.scheduleDao.getScheduleList()).map((row) => row.schedule.id), + ['later'], + ); + }, + ); + test( + 'store generation replacement invalidates a suspended snapshot even when rows are unchanged', + () async { + await insert(schedule('future', now.add(const Duration(days: 8)))); + final session = await repository.open(key()); + await LocalDataOperationGate.shared.run(() async {}, replacesData: true); + await expectLater( + session.advance(candidateBudget: 16), + throwsA(isA()), + ); + final current = + await finish(await repository.open(key())) as NearestQueryReady; + expect( + current.value.authority.key.generation, + LocalDataOperationGate.shared.generation, + ); + }, + ); + test( + 'winner materialization and its actual SQLite watch converge without repeated materialization', + () async { + final first = now.add(const Duration(days: 1)); + await recurring.create( + schedule('series', first), + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 3, + ), + ); + await db.delete(db.schedules).go(); + final observer = _ObservedQueryRepository(repository); + final seen = []; + final ready = Completer(); + final subscription = + GetNearestUpcomingScheduleUseCase( + observer, + now: () => now, + chunkSize: 1, + )(key: key()).listen((value) { + seen.add(value); + if (value is NearestQueryReady && !ready.isCompleted) { + ready.complete(value); + } + }); + try { + final selected = await ready.future.timeout(const Duration(seconds: 5)); + expect(selected.value.resolution.instantUtc, first); + // Initial DAO delivery and the owned insertion may each produce a fresh + // verified receipt. Require bounded quiescence, not exactly one receipt. + var quietTurns = 0; + var before = (observer.opens, seen.length, observer.changesSeen); + for (var turn = 0; turn < 200 && quietTurns < 10; turn++) { + await db.customSelect('SELECT 1').get(); + await Future.delayed(Duration.zero); + final after = (observer.opens, seen.length, observer.changesSeen); + quietTurns = observer.inFlight == 0 && after == before + ? quietTurns + 1 + : 0; + before = after; + } + expect( + quietTurns, + 10, + reason: + 'Known initial/self-write signals must reach quiescence under a finite bound.', + ); + expect( + seen.whereType().length, + inInclusiveRange(1, 2), + ); + expect( + observer.changesSeen, + 2, + reason: + 'Only initial SQLite delivery and the one owned insert are expected.', + ); + expect(observer.opens, lessThanOrEqualTo(observer.changesSeen + 1)); + expect(seen.whereType(), isEmpty); + final rows = await db.scheduleDao.getScheduleList(); + expect(rows, hasLength(1)); + expect(rows.single.schedule.id, selected.value.schedule.id); + expect( + selected.value.authority.dataRevision, + (await db.select(db.users).getSingle()).dataRevision, + ); + } finally { + await subscription.cancel(); + } + }, + ); + + test( + 'excluded, moved override, completed and frozen slots cannot be recreated over a nearer unmaterialized winner', + () async { + final first = now.add(const Duration(days: 1)); + await recurring.create( + schedule('series', first), + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 6, + ), + ); + final initial = + (await db.scheduleDao.getScheduleList()) + .map((v) => v.toScheduleEntity()) + .toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + await recurring.delete(initial[0], RecurringEditScope.occurrence); + await recurring.updateOccurrence( + initial[1], + initial[1].copyWith(scheduleTime: first.add(const Duration(days: 10))), + prep, + preparationChanged: false, + ); + await db.scheduleDao.updateSchedule( + initial[2] + .copyWith(doneStatus: ScheduleDoneStatus.normalEnd) + .toScheduleRow(), + ); + await db.scheduleDao.updateSchedule( + initial[3] + .copyWith(isStarted: true, startedAt: now, preparationFrozen: true) + .toScheduleRow(), + ); + await (db.delete( + db.schedules, + )..where((row) => row.id.equals(initial[4].id))).go(); + final preserved = { + for (final index in [1, 2, 3]) + initial[index].id: (await db.scheduleDao.getScheduleById( + initial[index].id, + )).schedule, + }; + final result = + await finish(await repository.open(key()), chunk: 1) + as NearestQueryReady; + expect(result.value.schedule.id, initial[4].id); + expect(result.value.schedule.recurringOrdinal, 5); + expect((await repository.readActive())!.id, initial[3].id); + final rows = await db.scheduleDao.getScheduleList(); + expect(rows, hasLength(5)); + expect(rows.any((row) => row.schedule.id == initial[0].id), isFalse); + for (final entry in preserved.entries) { + expect( + (await db.scheduleDao.getScheduleById(entry.key)).schedule, + entry.value, + ); + } + expect( + await db.select(db.recurringScheduleExclusions).get(), + hasLength(1), + ); + }, + ); +} diff --git a/test/data/repositories/preparation_restore_watch_test.dart b/test/data/repositories/preparation_restore_watch_test.dart new file mode 100644 index 00000000..94b17982 --- /dev/null +++ b/test/data/repositories/preparation_restore_watch_test.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/preparation_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; + +void main() { + for (final scenario in ['replacement', 'pending-resume', 'rollback']) { + test( + 'preparation consumer resumes after $scenario without another write', + () async { + final fixture = await _Fixture.create(); + addTearDown(fixture.close); + final gate = LocalDataOperationGate.shared; + expect(_name(await fixture.consumer('same')), 'old'); + if (scenario == 'rollback') { + await expectLater( + gate.run( + () => fixture.db.transaction(() async { + await fixture.rename('rolled back'); + throw StateError('injected rollback'); + }), + replacesData: true, + ), + throwsStateError, + ); + } else if (scenario == 'pending-resume') { + gate.setRecoveryPending(true); + await fixture.rename('new'); + await pumpEventQueue(); + expect(await fixture.repository.preparationStream.first, isEmpty); + gate.setRecoveryPending(false); + } else { + await gate.run(() async { + gate.setRecoveryPending(true); + await fixture.rename('new'); + }, replacesData: true); + await pumpEventQueue(); + expect(await fixture.repository.preparationStream.first, isEmpty); + gate.setRecoveryPending(false); + } + expect( + _name( + await fixture.consumer('same').timeout(const Duration(seconds: 5)), + ), + scenario == 'rollback' ? 'old' : 'new', + ); + }, + ); + } + for (final fails in [false, true]) { + test( + 'retired preparation read ${fails ? 'error' : 'value'} cannot publish after replacement', + () async { + final fixture = await _Fixture.create(latch: true); + addTearDown(fixture.close); + await fixture.source.entered.future; + final errors = []; + final seen = []; + final sub = fixture.repository.preparationStream.listen((map) { + if (map['same'] != null) seen.add(_name(map['same']!)); + }, onError: errors.add); + addTearDown(sub.cancel); + final gate = LocalDataOperationGate.shared; + final replacement = gate.run( + () => fixture.rename('new'), + replacesData: true, + ); + await pumpEventQueue(); + expect(gate.isReplacingData, isTrue); + // The old transaction must finish before the replacement can write. + // Releasing it after token retirement also exercises late failure. + fixture.source.fail = fails; + fixture.source.release.complete(); + await replacement; + expect( + _name( + await fixture.consumer('same').timeout(const Duration(seconds: 5)), + ), + 'new', + ); + await pumpEventQueue(); + expect(seen, isNot(contains('old'))); + expect(errors, isEmpty); + }, + ); + } + test( + 'dispose retires an outstanding preparation query before it completes', + () async { + final fixture = await _Fixture.create(latch: true); + await fixture.source.entered.future; + final errors = []; + final sub = fixture.repository.preparationStream.listen( + (_) {}, + onError: errors.add, + ); + final disposal = fixture.repository.dispose(); + fixture.source.fail = true; + fixture.source.release.complete(); + await disposal; + await sub.cancel(); + await fixture.db.close(); + expect(errors, isEmpty); + }, + ); +} + +String _name(PreparationEntity value) => + value.preparationStepList.single.preparationName; + +class _Fixture { + _Fixture(this.db, this.source, this.repository); + final AppDatabase db; + final _Source source; + final PreparationRepositoryImpl repository; + GetPreparationByScheduleIdUseCase get consumer => + GetPreparationByScheduleIdUseCase(repository); + static Future<_Fixture> create({bool latch = false}) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final schedule = ScheduleEntity( + id: 'same', + place: const PlaceEntity(id: 'place', placeName: 'Home'), + scheduleName: 'Synthetic', + scheduleTime: DateTime(2030), + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + await db.scheduleDao.createSchedule(schedule.toScheduleWithPlaceRow()); + await db.preparationScheduleDao.createPreparationSchedule( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'old', + preparationTime: Duration(minutes: 5), + ), + ], + ), + 'same', + ); + final source = _Source(db, latch); + final repo = PreparationRepositoryImpl( + preparationLocalDataSource: source, + userRepository: _User(), + database: db, + ); + return _Fixture(db, source, repo); + } + + Future rename(String name) async { + await (db.update(db.preparationSchedules) + ..where((t) => t.id.equals('step'))) + .write(PreparationSchedulesCompanion(preparationName: Value(name))); + } + + Future close() async { + await repository.dispose(); + LocalDataOperationGate.shared.setRecoveryPending(false); + await db.close(); + } +} + +class _Source extends PreparationLocalDataSourceImpl { + _Source(AppDatabase db, this.pause) : super(appDatabase: db); + bool pause; + bool fail = false; + final entered = Completer(); + final release = Completer(); + @override + Future getPreparationByScheduleId(String id) async { + final value = await super.getPreparationByScheduleId(id); + if (pause) { + pause = false; + entered.complete(); + await release.future; + if (fail) throw StateError('retired synthetic read'); + } + return value; + } +} + +class _User implements UserRepository { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/data/repositories/profile_write_preservation_test.dart b/test/data/repositories/profile_write_preservation_test.dart index e604c5b6..d0f99d96 100644 --- a/test/data/repositories/profile_write_preservation_test.dart +++ b/test/data/repositories/profile_write_preservation_test.dart @@ -242,6 +242,30 @@ void main() { await db.customStatement('DROP TRIGGER reject_revision'); await preparations.updateSpareTime(const Duration(minutes: 15)); expect((await row()).dataRevision, 43); + await db.userDao.updateAlarmSettings(userId: id, enabled: true); + expect((await row()).dataRevision, 44); + expect((await row()).alarmsEnabled, isTrue); + await db.userDao.updateDetailedNotificationContent( + userId: id, + enabled: false, + ); + expect((await row()).dataRevision, 45); + expect((await row()).detailedNotificationContent, isFalse); + await db.userDao.resetScore(id); + final recovered = await row(); + expect(recovered.dataRevision, 46); + expect(recovered.eligibleOutcomeCount, 0); + expect(recovered.onTimeOutcomeCount, 0); + expect(recovered.note, before.note); + expect(recovered.lastExportedRevision, before.lastExportedRevision); + expect(recovered.firstDurableDataAt, before.firstDurableDataAt); + await db.userDao.updateAlarmSettings(userId: id, enabled: true); + await db.userDao.updateDetailedNotificationContent( + userId: id, + enabled: false, + ); + await db.userDao.resetScore(id); + expect(await row(), recovered); }); test( @@ -255,6 +279,17 @@ void main() { final schedule = await schedules.getScheduleById('one'); expect(schedule.doneStatus, ScheduleDoneStatus.notEnded); expect(schedule.scoreContributionRecorded, false); + await db.customStatement('DROP TRIGGER reject_revision'); + await schedules.finishSchedule('one', 0); + final recovered = await row(); + expect(recovered.dataRevision, before.dataRevision + 1); + expect(recovered.eligibleOutcomeCount, before.eligibleOutcomeCount + 1); + expect(recovered.onTimeOutcomeCount, before.onTimeOutcomeCount + 1); + final finished = await schedules.getScheduleById('one'); + expect(finished.doneStatus, ScheduleDoneStatus.normalEnd); + expect(finished.scoreContributionRecorded, isTrue); + await schedules.finishSchedule('one', 0); + expect(await row(), recovered); }, ); diff --git a/test/data/repositories/recurring_schedule_repository_test.dart b/test/data/repositories/recurring_schedule_repository_test.dart index 4b27cd24..331856f2 100644 --- a/test/data/repositories/recurring_schedule_repository_test.dart +++ b/test/data/repositories/recurring_schedule_repository_test.dart @@ -223,10 +223,20 @@ void main() { final initial = await rows(); await repository.updateOccurrence( initial[1], - initial[1].copyWith(scheduleName: 'personal'), + initial[1].copyWith( + scheduleName: 'personal', + place: const PlaceEntity( + id: 'draft-personal-place', + placeName: 'Personal place', + ), + ), _prep('personal prep'), preparationChanged: true, ); + final personalBefore = (await rows())[1]; + final definitionBefore = await repository.getPreparation( + personalBefore.preparationDefinitionId!, + ); await repository.updateFollowing( initial.first, initial.first.copyWith( @@ -239,6 +249,21 @@ void main() { final all = await rows(); expect(all.map((s) => s.scheduleTime.hour), everyElement(11)); expect(all.map((s) => s.scheduleName), ['shared', 'personal', 'shared']); + expect(all[1].place, personalBefore.place); + expect( + all[1].preparationDefinitionId, + personalBefore.preparationDefinitionId, + ); + expect( + await repository.getPreparation(all[1].preparationDefinitionId!), + definitionBefore, + ); + expect( + (await db.select(db.places).get()).any( + (p) => p.id == personalBefore.place.id, + ), + isTrue, + ); expect( (await repository.getPreparation( all[1].preparationDefinitionId!, diff --git a/test/data/repositories/recurring_time_correction_repository_test.dart b/test/data/repositories/recurring_time_correction_repository_test.dart new file mode 100644 index 00000000..b5aba66d --- /dev/null +++ b/test/data/repositories/recurring_time_correction_repository_test.dart @@ -0,0 +1,754 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' show Variable; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_export_snapshot.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_json_reader.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/recurring_time_correction_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/data/repositories/schedule_time_correction_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; + +void main() { + late Directory directory; + late AppDatabase db; + late LocalDataOperationGate gate; + late RecurringScheduleRepositoryImpl recurring; + late ScheduleAggregateRepositoryImpl aggregates; + late ScheduleTimeCorrectionRepositoryImpl single; + late RecurringTimeCorrectionRepositoryImpl repository; + late DateTime now; + final start = DateTime.utc(2030, 1, 2, 10); + const preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'source-step', + preparationName: 'Ready', + preparationTime: Duration(minutes: 10), + ), + ], + ); + Future open() async { + db = AppDatabase.forTesting( + NativeDatabase(File('${directory.path}/app.sqlite')), + ); + recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + aggregates = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + single = ScheduleTimeCorrectionRepositoryImpl( + db, + aggregates, + gate: gate, + now: () => now, + ); + repository = RecurringTimeCorrectionRepositoryImpl( + db, + aggregates, + recurring, + gate: gate, + now: () => now, + ); + } + + Future> rows() async => + (await db.scheduleDao.getScheduleList()) + .map((e) => e.toScheduleEntity()) + .toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + setUp(() async { + now = DateTime.utc(2030, 1, 1); + directory = await Directory.systemTemp.createTemp( + 'ontime-bounded-time-plan-', + ); + gate = LocalDataOperationGate(); + await open(); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + await recurring.create( + ScheduleEntity( + id: 'source', + place: const PlaceEntity(id: 'office', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: start, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Preserve', + isChanged: false, + isStarted: false, + ), + preparation, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 3, + ), + ); + }); + tearDown(() async { + await db.close(); + gate.dispose(); + await directory.delete(recursive: true); + }); + + Future>> contents() async { + final tables = await db + .customSelect( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .get(); + return { + for (final table in tables) + table.read( + 'name', + ): (await db + .customSelect('SELECT * FROM "${table.read('name')}"') + .get()) + .map((e) => jsonEncode(e.data)) + .toList() + ..sort(), + }; + } + + Future review({ + int count = 3, + bool automatic = false, + }) async { + final anchor = (await rows()).first; + return repository.review( + RecurringTimeCorrectionRequest( + anchorReview: await single.review(anchor.id), + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start.add(const Duration(hours: 1)), + timeZoneId: 'UTC', + count: count, + ), + endExplicitlyChosen: !automatic, + ), + ); + } + + RecurringTimeCorrectionCommand command( + RecurringTimeCorrectionReview review, { + String mutation = 'apply-plan', + bool acknowledge = true, + }) => RecurringTimeCorrectionCommand( + review: review, + mutationId: mutation, + confirmedDetachedIds: acknowledge + ? review.mapping.rows + .where((e) => e.detached) + .map((e) => e.original.id) + .toSet() + : {}, + confirmedUnmatchedExclusions: acknowledge + ? review.mapping.exclusions + .where((e) => e.slot == null) + .map((e) => '${e.original.segmentId}\n${e.original.slot}') + .toSet() + : {}, + ); + + test( + 'single correction excludes its old slot but still sees a sibling occurrence', + () async { + final existing = await rows(); + final anchor = existing.first; + // A stored offset no longer matching the current UTC rule is a defensive + // stale-time fixture; all recurrence graph/ownership comes from create(). + await db.customStatement( + 'UPDATE schedules SET occurrence_offset_seconds=3600 WHERE id=?', + [anchor.id], + ); + final viewed = await single.review(anchor.id); + ScheduleTimeCorrectionCommand selection(DateTime civil) => + ScheduleTimeCorrectionCommand( + review: viewed, + civil: CivilDateTime.fromFields(civil), + timeZoneId: 'UTC', + offsetSeconds: 0, + mutationId: 'single-recurring', + ); + final before = await contents(); + final self = await single.reviewChoice(selection(start)); + expect(self.conflicts, isEmpty); + final sibling = await single.reviewChoice( + selection(existing[1].scheduleTime), + ); + expect( + sibling.conflicts.map((e) => e.other.schedule.id), + contains(existing[1].id), + ); + expect(await contents(), before); + await expectLater( + single.confirm(selection(existing[1].scheduleTime)), + throwsA(isA()), + ); + expect(await contents(), before); + expect((await single.confirm(selection(start))).changed, isTrue); + expect( + (await rows()).first.recurringOverrides.split(','), + contains('time'), + ); + }, + ); + + test( + 'unresolved materialized time override owns its original slot without a phantom base conflict', + () async { + final existing = await rows(); + final target = existing.first, uncertain = existing[1]; + await db.customStatement( + 'UPDATE schedules SET occurrence_offset_seconds=3600 WHERE id=?', + [target.id], + ); + // The recurrence/definition graph comes from the real create workflow; + // this raw row models a previously valid override whose zone was removed. + await db.customStatement( + "UPDATE schedules SET schedule_time=?, time_zone_id='Missing/Override', recurring_overrides='time' WHERE id=?", + ['2030-01-02T18:00:00.000', uncertain.id], + ); + final correction = ScheduleTimeCorrectionCommand( + review: await single.review(target.id), + civil: CivilDateTime.fromFields(uncertain.scheduleTime), + timeZoneId: 'UTC', + offsetSeconds: 0, + mutationId: 'resolve-target', + acknowledgedUncertainIds: {uncertain.id}, + ); + final before = await contents(); + final proof = await single.reviewChoice(correction); + expect(proof.conflicts, isEmpty); + expect(proof.possibleOverlaps.map((e) => e.id), contains(uncertain.id)); + expect(await contents(), before); + expect((await single.confirm(correction)).changed, isTrue); + final preserved = (await rows()).singleWhere((e) => e.id == uncertain.id); + expect(preserved.timeZoneId, 'Missing/Override'); + expect(preserved.scheduleTime, DateTime.utc(2030, 1, 2, 18)); + expect(preserved.recurringSlotKey, uncertain.recurringSlotKey); + }, + ); + + test( + 'following conflicts block, then explicit exclusion preserves the row and count budget', + () async { + final original = await rows(); + await db.scheduleDao.createSchedule( + ScheduleEntity( + id: 'other', + place: const PlaceEntity(id: 'other-place', placeName: 'Other'), + scheduleName: 'Other', + scheduleNote: '', + scheduleTime: start.add(const Duration(hours: 1)), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: false, + ).toScheduleWithPlaceRow(), + ); + final initial = await review(); + expect(initial.conflictProof!.conflicts, hasLength(1)); + final before = await contents(); + await expectLater( + repository.confirm(command(initial)), + throwsA(isA()), + ); + expect(await contents(), before); + final excluded = initial.conflictProof!.conflicts.single.slot; + final changed = await repository.review( + RecurringTimeCorrectionRequest( + anchorReview: initial.request.anchorReview, + rule: initial.request.rule, + endExplicitlyChosen: true, + excludedConflictSlots: {excluded.key}, + ), + ); + expect(changed.conflictProof!.conflicts, isEmpty); + expect( + changed.mapping.rows.where((e) => e.detached).single.original.id, + original.first.id, + ); + expect(changed.mapping.conflictExclusions.single.ordinal, 1); + await expectLater( + repository.confirm(command(changed, acknowledge: false)), + throwsA(isA()), + ); + expect(await contents(), before); + await repository.confirm(command(changed)); + final saved = await rows(); + final standalone = saved.singleWhere((e) => e.id == original.first.id); + expect(standalone.isRecurring, isFalse); + expect(standalone.scheduleTime, original.first.scheduleTime); + expect( + standalone.preparationDefinitionId, + original.first.preparationDefinitionId, + ); + final newId = saved.firstWhere((e) => e.isRecurring).recurringSegmentId!; + final newSegment = await recurring.getSegment(newId); + expect(newSegment.rule.count, 3); + final tombstone = await db + .customSelect( + 'SELECT * FROM recurring_schedule_exclusions WHERE segment_id=?', + variables: [Variable(newId)], + ) + .getSingle(); + expect(tombstone.read('ordinal'), 1); + await recurring.materialize(now, DateTime.utc(2030, 2)); + expect((await rows()).map((e) => e.id).toSet(), { + ...original.map((e) => e.id), + 'other', + }); + final snapshot = await BackupExportSnapshot.create( + db, + cutoff: now, + sourceAppVersion: 'test', + sourcePlatform: 'test', + budget: BackupBudget(), + stagingFactory: () async { + final target = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(target, target.close); + }, + ); + try { + final bytes = await snapshot.plaintext().expand((e) => e).toList(); + final budget = BackupBudget(); + final ingestion = BackupIngestionStore.memoryForTesting(budget); + try { + final node = await BackupJsonReader( + ingestion, + budget, + ).read(Stream.value(bytes)); + await BackupValidatedIngestion.validateTestStore( + ingestion, + node, + nowUtc: now, + ); + } finally { + await ingestion.release(); + } + } finally { + await snapshot.release(); + } + }, + ); + test( + 'arbitrary non-conflicting slots cannot be supplied as conflict exclusions', + () async { + final initial = await review(); + final before = await contents(); + await expectLater( + repository.review( + RecurringTimeCorrectionRequest( + anchorReview: initial.request.anchorReview, + rule: initial.request.rule, + endExplicitlyChosen: true, + excludedConflictSlots: {initial.mapping.firstSlot!.key}, + ), + ), + throwsA(isA()), + ); + expect(await contents(), before); + }, + ); + test( + 'excluded materialized row is rechecked against the remaining new rule', + () async { + final anchor = (await rows()).first; + final first = start.add(const Duration(hours: 1)); + // A long preparation makes the preserved original overlap a later slot. + await db.customStatement( + 'UPDATE preparation_definition_steps SET minutes=1560', + ); + await db.scheduleDao.createSchedule( + ScheduleEntity( + id: 'other', + place: const PlaceEntity(id: 'other-place', placeName: 'Other'), + scheduleName: 'Other', + scheduleNote: '', + scheduleTime: first, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: false, + ).toScheduleWithPlaceRow(), + ); + final viewed = await single.review(anchor.id); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: first, + timeZoneId: 'UTC', + count: 3, + ); + final initial = await repository.review( + RecurringTimeCorrectionRequest( + anchorReview: viewed, + rule: rule, + endExplicitlyChosen: true, + ), + ); + expect( + initial.conflictProof!.conflicts.any( + (e) => e.slot.key == first.toIso8601String(), + ), + isTrue, + ); + final changed = await repository.review( + RecurringTimeCorrectionRequest( + anchorReview: viewed, + rule: rule, + endExplicitlyChosen: true, + excludedConflictSlots: {first.toIso8601String()}, + ), + ); + expect( + changed.conflictProof!.conflicts.any( + (e) => e.other.schedule.id == anchor.id, + ), + isTrue, + ); + final before = await contents(); + await expectLater( + repository.confirm(command(changed)), + throwsA(isA()), + ); + expect(await contents(), before); + }, + ); + + test( + 'finite review is read-only and apply preserves IDs, preparation owners and one revision', + () async { + final original = await rows(); + final before = await contents(); + final planned = await review(automatic: true); + expect(planned.mapping.automaticallyRetainedCount, isTrue); + expect(planned.mapping.rule.count, 3); + expect(planned.mapping.workUnits, lessThan(100)); + expect(await contents(), before); + final receipt = await repository.confirm(command(planned)); + expect(receipt.changed, isTrue); + final saved = await rows(); + expect(saved.map((e) => e.id), original.map((e) => e.id)); + expect( + saved.map((e) => e.preparationDefinitionId), + original.map((e) => e.preparationDefinitionId), + ); + expect(saved.map((e) => e.scheduleTime.hour), everyElement(11)); + expect(saved.map((e) => e.recurringSegmentId).toSet(), hasLength(1)); + expect( + saved.first.recurringSegmentId, + isNot(original.first.recurringSegmentId), + ); + expect((await db.select(db.users).getSingle()).dataRevision, 2); + // Stable IDs differ from the generated UUID for the new segment. A later + // normal materialization must recognize the occupied segment+slot. + await recurring.materialize(start, DateTime.utc(2030, 2, 1)); + expect((await rows()).map((e) => e.id), original.map((e) => e.id)); + }, + ); + test( + 'exact retries including reopen reuse the same row and root receipt', + () async { + final plan = command(await review()); + expect((await repository.confirm(plan)).changed, isTrue); + final saved = await contents(); + expect((await repository.confirm(plan)).changed, isFalse); + await db.close(); + await open(); + expect((await repository.confirm(plan)).changed, isFalse); + expect(await contents(), saved); + }, + ); + test( + 'revision failure rolls back every close, insert, mapped row and receipt', + () async { + final plan = command(await review()); + await db.customStatement( + "CREATE TRIGGER fail_plan BEFORE UPDATE OF data_revision ON users BEGIN SELECT RAISE(ABORT, 'a10-plan-revision'); END", + ); + final before = await contents(); + await expectLater( + repository.confirm(plan), + throwsA(predicate((e) => e.toString().contains('a10-plan-revision'))), + ); + expect(await contents(), before); + await db.customStatement('DROP TRIGGER fail_plan'); + expect((await repository.confirm(plan)).changed, isTrue); + }, + ); + test( + 'excess rows require exact detach acknowledgement and remain stable standalones', + () async { + final original = await rows(); + final planned = await review(count: 1); + expect(planned.mapping.rows.where((e) => e.detached), hasLength(2)); + final before = await contents(); + await expectLater( + repository.confirm(command(planned, acknowledge: false)), + throwsA(isA()), + ); + expect(await contents(), before); + await repository.confirm(command(planned)); + final saved = await rows(); + expect(saved.map((e) => e.id).toSet(), original.map((e) => e.id).toSet()); + expect(saved.where((e) => !e.isRecurring), hasLength(2)); + expect( + saved.map((e) => e.preparationDefinitionId).toSet(), + original.map((e) => e.preparationDefinitionId).toSet(), + ); + // Actual ordinary SQLite export/ingestion/portable copy, not SQLCipher UI. + final snapshot = await BackupExportSnapshot.create( + db, + cutoff: now, + sourceAppVersion: 'test', + sourcePlatform: 'test', + budget: BackupBudget(), + stagingFactory: () async { + final target = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(target, target.close); + }, + ); + try { + final bytes = await snapshot.plaintext().expand((e) => e).toList(); + final budget = BackupBudget(); + final ingestion = BackupIngestionStore.memoryForTesting(budget); + try { + final node = await BackupJsonReader( + ingestion, + budget, + ).read(Stream.value(bytes)); + final content = await BackupValidatedIngestion.validateTestStore( + ingestion, + node, + nowUtc: now, + ); + final target = AppDatabase.forTesting(NativeDatabase.memory()); + try { + await content.materialize(target, pendingCleanup: false); + await content.validateReadBack(target, pendingCleanup: false); + expect( + (await target.select(target.schedules).get()) + .map((e) => e.id) + .toSet(), + original.map((e) => e.id).toSet(), + ); + expect( + (await target.select(target.schedules).get()) + .map((e) => e.preparationDefinitionId) + .toSet(), + original.map((e) => e.preparationDefinitionId).toSet(), + ); + } finally { + await target.close(); + } + } finally { + await ingestion.release(); + } + } finally { + await snapshot.release(); + } + }, + ); + test( + 'concurrent same intent commits once; same ID with another plan conflicts', + () async { + final plan = command(await review()); + final receipts = await Future.wait([ + repository.confirm(plan), + repository.confirm(plan), + ]); + expect(receipts.where((e) => e.changed), hasLength(1)); + final changed = command(await review(count: 2)); + await expectLater( + repository.confirm(changed), + throwsA(isA()), + ); + expect((await db.select(db.users).getSingle()).dataRevision, 2); + }, + ); + test('stale source edit cannot be hidden by a forged old mapping', () async { + final plan = command(await review()); + await db.customStatement( + "UPDATE schedules SET schedule_note = 'changed' WHERE id = ?", + [(await rows()).last.id], + ); + final before = await contents(); + await expectLater( + repository.confirm(plan), + throwsA(isA()), + ); + expect(await contents(), before); + }); + test( + 'clock rewind and target preparation boundary reject before writing', + () async { + final plan = command(await review()); + final before = await contents(); + now = now.subtract(const Duration(seconds: 1)); + await expectLater( + repository.confirm(plan), + throwsA(isA()), + ); + expect(await contents(), before); + now = start.add(const Duration(minutes: 55)); + await expectLater( + repository.confirm(plan), + throwsA(isA()), + ); + expect(await contents(), before); + }, + ); + test( + 'unresolved time override is listed by ID and cannot acquire the base rule offset', + () async { + final override = (await rows())[1]; + await recurring.updateOccurrence( + override, + override.copyWith(timeZoneId: 'Africa/Abidjan'), + preparation, + preparationChanged: false, + ); + // Model a once-valid persisted zone absent from this registry, not a new + // user write bypassing form validation. + await db.customStatement( + "UPDATE schedules SET time_zone_id = 'Unknown/Retired' WHERE id = ?", + [override.id], + ); + final planned = await review(); + expect(planned.unresolvedOverrides.keys, [override.id]); + final before = await contents(); + await expectLater( + repository.confirm(command(planned)), + throwsA(isA()), + ); + expect(await contents(), before); + }, + ); + test( + 'an unmatched tombstone is retained and requires exact acknowledgement', + () async { + final old = (await rows())[1]; + await recurring.delete(old, RecurringEditScope.occurrence); + final planned = await review(count: 1); + expect(planned.mapping.exclusions.single.slot, isNull); + await expectLater( + repository.confirm(command(planned, acknowledge: false)), + throwsA(isA()), + ); + await repository.confirm(command(planned)); + final exclusion = await db + .select(db.recurringScheduleExclusions) + .getSingle(); + expect(exclusion.segmentId, old.recurringSegmentId); + expect(exclusion.slotKey, old.recurringSlotKey); + await recurring.materialize(start, DateTime.utc(2030, 2, 1)); + expect((await rows()).where((e) => e.id == old.id), isEmpty); + }, + ); + test( + 'actual started future occurrence remains in the closed source while its new slot is excluded', + () async { + final protected = (await rows())[1]; + final writer = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: _UnusedTimed(), + now: () => now, + ); + try { + await writer.startSchedule(protected.id, startedAt: now); + } finally { + await writer.dispose(); + } + final before = + (await db + .customSelect( + 'SELECT * FROM schedules WHERE id = ?', + variables: [Variable(protected.id)], + ) + .getSingle()) + .data; + final planned = await review(); + expect(planned.mapping.protectedRows.map((e) => e.id), [protected.id]); + expect(planned.mapping.protectedSlots.single.ordinal, 2); + await repository.confirm(command(planned)); + final after = + (await db + .customSelect( + 'SELECT * FROM schedules WHERE id = ?', + variables: [Variable(protected.id)], + ) + .getSingle()) + .data; + expect(after, before); + final retained = (await rows()).singleWhere((e) => e.id == protected.id); + expect(retained.retainedRecurringReference, isTrue); + expect(retained.isStarted, isTrue); + final excluded = await db + .select(db.recurringScheduleExclusions) + .getSingle(); + expect(excluded.ordinal, 2); + expect(excluded.segmentId, isNot(protected.recurringSegmentId)); + await recurring.materialize(start, DateTime.utc(2030, 2, 1)); + expect(await rows(), hasLength(3)); + }, + ); + + test( + 'a later same-series edit invalidates an old exact retry receipt', + () async { + final plan = command(await review()); + await repository.confirm(plan); + final other = (await rows()).last; + await db.customStatement( + "UPDATE schedules SET schedule_note = 'later edit' WHERE id = ?", + [other.id], + ); + final before = await contents(); + await expectLater( + repository.confirm(plan), + throwsA(isA()), + ); + expect(await contents(), before); + }, + ); +} + +class _UnusedTimed extends Fake implements TimedPreparationRepository {} diff --git a/test/data/repositories/recurring_time_override_test.dart b/test/data/repositories/recurring_time_override_test.dart new file mode 100644 index 00000000..a95ab356 --- /dev/null +++ b/test/data/repositories/recurring_time_override_test.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; + +void main() { + late Directory directory; + late AppDatabase db; + late RecurringScheduleRepositoryImpl repository; + final start = DateTime.utc(2030, 1, 2, 10); + const preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'source-step', + preparationName: 'Get ready', + preparationTime: Duration(minutes: 10), + ), + ], + ); + + Future open() async { + db = AppDatabase.forTesting( + NativeDatabase(File('${directory.path}/app.sqlite')), + ); + repository = RecurringScheduleRepositoryImpl( + db, + now: () => DateTime.utc(2030, 1, 1), + ); + } + + Future> rows() async => + (await db.scheduleDao.getScheduleList()) + .map((row) => row.toScheduleEntity()) + .toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + + RecurrenceRule rule(DateTime at) => RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: at, + timeZoneId: 'UTC', + count: 3, + ); + + setUp(() async { + directory = await Directory.systemTemp.createTemp('ontime-time-override-'); + await open(); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + await repository.create( + ScheduleEntity( + id: 'series', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: start, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Keep the appointment', + ), + preparation, + rule(start), + ); + }); + + tearDown(() async { + await db.close(); + await directory.delete(recursive: true); + }); + + test( + 'a zone-only edit with the same offset is an explicit time override', + () async { + final original = (await rows())[1]; + await repository.updateOccurrence( + original, + original.copyWith(timeZoneId: 'Africa/Abidjan'), + preparation, + preparationChanged: false, + ); + final updated = (await rows()).singleWhere( + (row) => row.id == original.id, + ); + expect(updated.scheduleTime, DateTime.utc(2030, 1, 3, 10)); + expect(updated.timeZoneId, 'Africa/Abidjan'); + expect(updated.occurrenceOffsetSeconds, 0); + expect(updated.recurringOverrides.split(','), contains('time')); + expect(updated.recurringSlotKey, original.recurringSlotKey); + expect(updated.recurringOrdinal, original.recurringOrdinal); + }, + ); + + for (final choice in [ + (zone: 'Africa/Abidjan', offset: 0, utcHour: 10), + (zone: 'Asia/Seoul', offset: 32400, utcHour: 1), + ]) { + test( + 'following edits and reopen preserve the complete ${choice.zone} time override', + () async { + final initial = await rows(); + final original = initial[1]; + await repository.updateOccurrence( + original, + original.copyWith( + timeZoneId: choice.zone, + occurrenceOffsetSeconds: choice.offset, + ), + preparation, + preparationChanged: false, + ); + // Exercise the public workflow, not _mergeOverrides or fabricated rows. + // Change the base hour so a missing time mask is observable as well. + final newStart = start.add(const Duration(hours: 1)); + await repository.updateFollowing( + initial.first, + initial.first.copyWith(scheduleTime: newStart), + preparation, + rule(newStart), + ); + await repository.materialize(start, DateTime.utc(2030, 1, 8)); + final beforeReopen = await rows(); + expect(beforeReopen, hasLength(3)); + final overridden = beforeReopen.singleWhere( + (row) => row.scheduleTime.day == 3, + ); + expect(overridden.scheduleTime, DateTime.utc(2030, 1, 3, 10)); + expect(overridden.timeZoneId, choice.zone); + expect(overridden.occurrenceOffsetSeconds, choice.offset); + expect( + overridden.occurrenceInstantUtc, + DateTime.utc(2030, 1, 3, choice.utcHour), + ); + expect(overridden.recurringOverrides.split(','), contains('time')); + expect( + beforeReopen + .where((row) => row.scheduleTime.day != 3) + .map((row) => row.scheduleTime.hour), + [11, 11], + ); + expect( + beforeReopen + .where((row) => row.scheduleTime.day != 3) + .map((row) => row.timeZoneId), + everyElement('UTC'), + ); + + final savedIds = beforeReopen.map((row) => row.id).toList(); + await db.close(); + await open(); + await repository.materialize(start, DateTime.utc(2030, 1, 8)); + final reopened = await rows(); + expect(reopened.map((row) => row.id), savedIds); + final retained = reopened.singleWhere( + (row) => row.scheduleTime.day == 3, + ); + expect(retained.scheduleTime, DateTime.utc(2030, 1, 3, 10)); + expect(retained.timeZoneId, choice.zone); + expect(retained.occurrenceOffsetSeconds, choice.offset); + expect( + retained.occurrenceInstantUtc, + DateTime.utc(2030, 1, 3, choice.utcHour), + ); + }, + ); + } +} diff --git a/test/data/repositories/schedule_aggregate_snapshot_test.dart b/test/data/repositories/schedule_aggregate_snapshot_test.dart index 5b569ca2..38e2768f 100644 --- a/test/data/repositories/schedule_aggregate_snapshot_test.dart +++ b/test/data/repositories/schedule_aggregate_snapshot_test.dart @@ -1,3 +1,6 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:convert'; import 'package:on_time_front/core/backup/backup_service.dart'; import 'package:on_time_front/core/backup/backup_crypto.dart'; @@ -59,6 +62,8 @@ class _Metadata implements AppMetadataProvider { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); for (final reader in ['edit', 'planning']) { test( '$reader snapshot cannot combine old schedule with new preparation', @@ -537,6 +542,11 @@ void main() { db, _Metadata(), NoopAlarmCleanup(), + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, crypto: crypto, operationGate: LocalDataOperationGate(), ); diff --git a/test/data/repositories/schedule_deletion_privacy_test.dart b/test/data/repositories/schedule_deletion_privacy_test.dart new file mode 100644 index 00000000..dc5d3a55 --- /dev/null +++ b/test/data/repositories/schedule_deletion_privacy_test.dart @@ -0,0 +1,303 @@ +import 'package:drift/drift.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import '../../helpers/schedule_deletion_fixture.dart'; + +void main() { + late ScheduleDeletionFixture f; + setUp(() async { + f = ScheduleDeletionFixture(); + await f.initialize(); + }); + tearDown(() => f.close()); + + for (final status in ScheduleDoneStatus.values) { + test( + '$status history deletion removes owned text and preserves score', + () async { + await f.create('remove'); + await f.create('keep'); + await f.outcome('remove', status); + await f.outcome('keep', ScheduleDoneStatus.lateEnd); + f.now = DateTime.utc(2031); + final beforeScore = await f.score(); + final beforeRevision = await f.revision(); + final keep = (await f.db.scheduleDao.getScheduleById('keep')).schedule; + final steps = await (f.db.select( + f.db.preparationSchedules, + )..where((t) => t.scheduleId.equals('remove'))).get(); + expect(steps, hasLength(2)); + expect(steps.where((s) => s.nextPreparationId != null), hasLength(1)); + expect( + (await f.db.customSelect('PRAGMA foreign_keys').getSingle()) + .data + .values + .single, + 1, + ); + + final intent = await f.aggregate.readForDeletion('remove'); + final commit = await f.aggregate.delete(intent); + + expect(commit.removedIds, {'remove'}); + expect(commit.changed, isTrue); + expect(await f.revision(), beforeRevision + 1); + expect(await f.score(), beforeScore); + expect( + await (f.db.select( + f.db.schedules, + )..where((t) => t.id.equals('remove'))).get(), + isEmpty, + ); + expect( + await (f.db.select( + f.db.preparationSchedules, + )..where((t) => t.scheduleId.equals('remove'))).get(), + isEmpty, + ); + expect( + await (f.db.select( + f.db.places, + )..where((t) => t.id.equals('place-remove'))).get(), + isEmpty, + ); + expect((await f.db.scheduleDao.getScheduleById('keep')).schedule, keep); + expect( + await f.db.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + }, + ); + } + + test( + 'deletion after score reset cannot resurrect or decrement old contributions', + () async { + await f.create('old'); + await f.create('new'); + await f.create('retained-old'); + await f.create('after-deletion'); + await f.outcome('old', ScheduleDoneStatus.normalEnd); + await f.outcome('retained-old', ScheduleDoneStatus.normalEnd); + await f.db.userDao.resetScore('local-profile'); + await f.outcome('new', ScheduleDoneStatus.lateEnd); + expect(await f.score(), (eligible: 1, onTime: 0)); + final retained = (await f.db.scheduleDao.getScheduleById( + 'retained-old', + )).schedule; + expect(retained.scoreContributionRecorded, isTrue); + expect( + (await f.db.scheduleDao.getScheduleById( + 'old', + )).schedule.scoreContributionRecorded, + isTrue, + ); + expect( + (await f.db.scheduleDao.getScheduleById( + 'new', + )).schedule.scoreContributionRecorded, + isTrue, + ); + f.now = DateTime.utc(2031); + + await f.aggregate.delete(await f.aggregate.readForDeletion('old')); + expect(await f.score(), (eligible: 1, onTime: 0)); + await f.aggregate.delete(await f.aggregate.readForDeletion('new')); + expect(await f.score(), (eligible: 1, onTime: 0)); + expect( + (await f.db.scheduleDao.getScheduleById('retained-old')).schedule, + retained, + ); + // Reset has no timestamp column: preserved completion/contribution markers + // prevent old outcomes from re-entering the new aggregation period. + await f.schedules.finishSchedule('retained-old', 0); + expect(await f.score(), (eligible: 1, onTime: 0)); + await f.outcome('after-deletion', ScheduleDoneStatus.normalEnd); + expect(await f.score(), (eligible: 2, onTime: 1)); + }, + ); + + test( + 'shared Place uses actual references; same-name orphan is never swept', + () async { + const shared = PlaceEntity(id: 'shared-place', placeName: 'Same name'); + await f.create('remove', place: shared); + await f.create('keep', place: shared); + await f.db + .into(f.db.places) + .insert( + PlacesCompanion.insert( + id: const Value('unrelated-orphan'), + placeName: 'Same name', + ), + ); + f.now = DateTime.utc(2031); + + await f.aggregate.delete(await f.aggregate.readForDeletion('remove')); + expect((await f.db.select(f.db.places).get()).map((p) => p.id).toSet(), { + 'shared-place', + 'unrelated-orphan', + }); + expect( + (await f.db.scheduleDao.getScheduleById('keep')).place.id, + 'shared-place', + ); + await f.aggregate.delete(await f.aggregate.readForDeletion('keep')); + expect((await f.db.select(f.db.places).get()).map((p) => p.id), [ + 'unrelated-orphan', + ]); + }, + ); + + test( + 'last materialized occurrence retains Place referenced only by segment JSON', + () async { + final at = DateTime.utc(2030, 1, 2, 10); + await f.recurring.create( + f.schedule('series', at: at), + f.preparation('shared-series'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: at, + timeZoneId: 'UTC', + count: 1, + ), + ); + final row = (await f.db.scheduleDao.getScheduleList()).single + .toScheduleEntity(); + final segments = await f.db.select(f.db.recurringScheduleSegments).get(); + final definitions = await f.db.select(f.db.preparationDefinitions).get(); + f.now = DateTime.utc(2031); + final before = await f.revision(); + + await f.aggregate.delete(await f.aggregate.readForDeletion(row.id)); + + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(await f.revision(), before + 1); + final currentSegments = await f.db + .select(f.db.recurringScheduleSegments) + .get(); + expect(currentSegments, hasLength(1)); + final current = currentSegments.single; + final beforeSegment = segments.single; + // Exclusion updates local concurrency metadata, not the reusable rule. + expect(current.id, beforeSegment.id); + expect(current.seriesId, beforeSegment.seriesId); + expect(current.ruleJson, beforeSegment.ruleJson); + expect(current.scheduleJson, beforeSegment.scheduleJson); + expect(current.preparationId, beforeSegment.preparationId); + expect(current.fromSlot, beforeSegment.fromSlot); + expect(current.beforeSlot, beforeSegment.beforeSlot); + expect(current.preparationNotBefore, beforeSegment.preparationNotBefore); + expect(await f.db.select(f.db.preparationDefinitions).get(), definitions); + expect((await f.db.select(f.db.places).get()).map((p) => p.id), [ + row.place.id, + ]); + await f.recurring.materialize(at, DateTime.utc(2032)); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + final exclusion = + (await f.db.select(f.db.recurringScheduleExclusions).get()).single; + expect(exclusion.segmentId, row.recurringSegmentId); + expect(exclusion.slotKey, row.recurringSlotKey); + expect(exclusion.ordinal, row.recurringOrdinal); + }, + ); + + test( + 'occurrence override definition is erased while shared series survives', + () async { + final at = DateTime.utc(2030, 1, 2, 10); + await f.recurring.create( + f.schedule('series', at: at), + f.preparation('shared-series'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: at, + timeZoneId: 'UTC', + count: 2, + ), + ); + final initial = await f.db.scheduleDao.getScheduleList(); + final target = initial.first.toScheduleEntity(); + final other = initial.last.toScheduleEntity(); + final sharedDefinition = target.preparationDefinitionId!; + await f.recurring.updateOccurrence( + target, + target.copyWith(scheduleName: 'Private override'), + f.preparation('private-override'), + preparationChanged: true, + ); + final edited = (await f.db.scheduleDao.getScheduleById( + target.id, + )).toScheduleEntity(); + final ownedDefinition = edited.preparationDefinitionId!; + expect(ownedDefinition, isNot(sharedDefinition)); + // Deliberately collide human-readable names across distinct live IDs. + await f.db + .update(f.db.preparationDefinitions) + .write( + const PreparationDefinitionsCompanion( + name: Value('Identical definition'), + ), + ); + await f.db + .update(f.db.preparationDefinitionSteps) + .write( + const PreparationDefinitionStepsCompanion( + name: Value('Identical step'), + ), + ); + final sharedBefore = await (f.db.select( + f.db.preparationDefinitions, + )..where((t) => t.id.equals(sharedDefinition))).get(); + final sharedStepsBefore = await (f.db.select( + f.db.preparationDefinitionSteps, + )..where((t) => t.definitionId.equals(sharedDefinition))).get(); + await f.outcome(edited.id, ScheduleDoneStatus.abnormalEnd); + f.now = DateTime.utc(2031); + + await f.aggregate.delete(await f.aggregate.readForDeletion(edited.id)); + + expect( + await (f.db.select( + f.db.preparationDefinitions, + )..where((t) => t.id.equals(ownedDefinition))).get(), + isEmpty, + ); + expect( + await (f.db.select( + f.db.preparationDefinitionSteps, + )..where((t) => t.definitionId.equals(ownedDefinition))).get(), + isEmpty, + ); + expect( + await (f.db.select( + f.db.preparationDefinitions, + )..where((t) => t.id.equals(sharedDefinition))).get(), + sharedBefore, + ); + expect( + await (f.db.select( + f.db.preparationDefinitionSteps, + )..where((t) => t.definitionId.equals(sharedDefinition))).get(), + sharedStepsBefore, + ); + expect( + (await f.db.scheduleDao.getScheduleById(other.id)).toScheduleEntity(), + other, + ); + await f.recurring.materialize(at, DateTime.utc(2032)); + expect((await f.db.select(f.db.schedules).get()).map((s) => s.id), [ + other.id, + ]); + expect( + await f.db.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + }, + ); +} diff --git a/test/data/repositories/schedule_deletion_repository_test.dart b/test/data/repositories/schedule_deletion_repository_test.dart new file mode 100644 index 00000000..aedd2a8a --- /dev/null +++ b/test/data/repositories/schedule_deletion_repository_test.dart @@ -0,0 +1,525 @@ +import 'dart:io'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; + +void main() { + late AppDatabase db; + late LocalDataOperationGate gate; + late RecurringScheduleRepositoryImpl recurring; + late ScheduleAggregateRepositoryImpl aggregate; + final now = DateTime.utc(2029); + final start = DateTime.utc(2030, 1, 1, 10); + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + gate = LocalDataOperationGate(); + recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + aggregate = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + await db.userDao.putUser( + const UserEntity(id: 'local-profile', spareTime: Duration.zero, note: ''), + ); + }); + tearDown(() async { + await db.close(); + gate.dispose(); + }); + Future revision() async => + (await db.select(db.users).getSingle()).dataRevision; + Future create([String id = 'one']) async { + await db.scheduleDao.createSchedule( + _schedule(id, start).toScheduleWithPlaceRow(), + ); + await db.preparationScheduleDao.createPreparationSchedule(_prep(id), id); + } + + Matcher rejected(ScheduleDeletionFailure failure) => throwsA( + isA().having( + (e) => e.failure, + 'failure', + failure, + ), + ); + + test( + 'confirmed intent commits once then reports current absence without another revision', + () async { + await create(); + final intent = await aggregate.readForDeletion('one'); + final before = await revision(); + final first = await aggregate.delete(intent); + final repeated = await aggregate.delete(intent); + expect(first.changed, isTrue); + expect(first.alreadyAbsent, isFalse); + expect(first.removedIds, {'one'}); + expect(repeated.changed, isFalse); + expect(repeated.alreadyAbsent, isTrue); + expect(repeated.removedIds, isEmpty); + expect(await revision(), before + 1); + expect(await aggregate.isDeletionCurrent(first), isTrue); + }, + ); + + test( + 'unrelated profile revision and schedule edit do not invalidate selected intent', + () async { + await create(); + await create('other'); + final intent = await aggregate.readForDeletion('one'); + await db.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 7), + ); + await (db.update(db.schedules)..where((t) => t.id.equals('other'))).write( + const SchedulesCompanion(scheduleName: Value('other edit')), + ); + final before = await revision(); + expect((await aggregate.delete(intent)).changed, isTrue); + expect(await revision(), before + 1); + expect( + (await db.scheduleDao.getScheduleById('other')).schedule.scheduleName, + 'other edit', + ); + }, + ); + + for (final mutation in ['edit', 'start', 'finish', 'ABA', 'step']) { + test( + '$mutation after confirmation rejects stale target and preserves current row', + () async { + await create(); + final intent = await aggregate.readForDeletion('one'); + final table = db.update(db.schedules)..where((t) => t.id.equals('one')); + switch (mutation) { + case 'edit': + await table.write( + const SchedulesCompanion(scheduleName: Value('edited')), + ); + case 'start': + await table.write(const SchedulesCompanion(isStarted: Value(true))); + case 'finish': + await table.write( + const SchedulesCompanion(doneStatus: Value('normalEnd')), + ); + case 'ABA': + await table.write( + const SchedulesCompanion(scheduleName: Value('changed')), + ); + await table.write( + const SchedulesCompanion(scheduleName: Value('one')), + ); + case 'step': + await (db.update( + db.preparationSchedules, + )..where((t) => t.scheduleId.equals('one'))).write( + const PreparationSchedulesCompanion( + preparationName: Value('edited step'), + ), + ); + } + final before = await revision(); + await expectLater( + aggregate.delete(intent), + rejected(ScheduleDeletionFailure.conflict), + ); + expect((await db.select(db.schedules).get()).length, 1); + expect(await revision(), before); + }, + ); + } + + test( + 'same ID recreation has a different incarnation and cannot consume old intent or retry receipt', + () async { + await create(); + final intent = await aggregate.readForDeletion('one'); + final receipt = await aggregate.delete(intent); + await create(); + final recreated = await aggregate.readForDeletion('one'); + expect( + recreated.snapshot.baseline.incarnation, + isNot(intent.snapshot.baseline.incarnation), + ); + await expectLater( + aggregate.delete(intent), + rejected(ScheduleDeletionFailure.conflict), + ); + expect(await aggregate.isDeletionCurrent(receipt), isFalse); + }, + ); + + test( + 'replacement generation rejects an old intent before any deletion', + () async { + await create(); + final intent = await aggregate.readForDeletion('one'); + await gate.run(() async {}, replacesData: true); + final before = await revision(); + await expectLater( + aggregate.delete(intent), + rejected(ScheduleDeletionFailure.conflict), + ); + expect((await db.select(db.schedules).get()).length, 1); + expect(await revision(), before); + }, + ); + + test( + 'different store identity rejects intent even with unchanged generation', + () async { + await create(); + final intent = await aggregate.readForDeletion('one'); + await (db.update(db.users)).write( + const UsersCompanion(storeIncarnation: Value('new-installation')), + ); + await expectLater( + aggregate.delete(intent), + rejected(ScheduleDeletionFailure.conflict), + ); + expect((await db.select(db.schedules).get()).length, 1); + }, + ); + + for (final fault in ['step', 'schedule', 'place', 'revision']) { + test( + '$fault failure rolls back deletion and original intent can retry', + () async { + await create(); + final intent = await aggregate.readForDeletion('one'); + final before = await revision(); + final event = { + 'step': 'DELETE ON preparation_schedules', + 'schedule': 'DELETE ON schedules', + 'place': 'DELETE ON places', + 'revision': 'UPDATE OF data_revision ON users', + }[fault]; + await db.customStatement( + "CREATE TRIGGER reject_delete BEFORE $event BEGIN SELECT RAISE(ABORT, 'injected'); END", + ); + await expectLater(aggregate.delete(intent), throwsA(anything)); + expect((await db.select(db.schedules).get()).length, 1); + expect((await db.select(db.preparationSchedules).get()).length, 2); + expect((await db.select(db.places).get()).length, 1); + expect(await revision(), before); + expect( + (await aggregate.readForDeletion('one')).snapshot.baseline.version, + intent.snapshot.baseline.version, + ); + await db.customStatement('DROP TRIGGER reject_delete'); + expect((await aggregate.delete(intent)).changed, isTrue); + expect(await revision(), before + 1); + }, + ); + } + + for (final fault in ['unlink', 'exclusion', 'revision-after-exclusion']) { + test( + '$fault restores the complete affected SQLite graph after rollback', + () async { + var id = 'one'; + if (fault == 'unlink') { + await create(); + } else { + await recurring.create( + _schedule('series', start), + _prep('shared'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 3, + ), + ); + final original = (await db.scheduleDao.getScheduleList()).first + .toScheduleEntity(); + await recurring.updateOccurrence( + original, + original.copyWith(scheduleName: 'Private override'), + _prep('private'), + preparationChanged: true, + ); + id = original.id; + await db.preparationScheduleDao.createPreparationSchedule( + _prep('linked'), + id, + ); + } + // Nonzero prior contributions need not have retained detail rows. + await db + .update(db.users) + .write( + const UsersCompanion( + eligibleOutcomeCount: Value(3), + onTimeOutcomeCount: Value(2), + ), + ); + final intent = await aggregate.readForDeletion(id); + final before = await _deletionGraph(db); + final beforeRevision = await revision(); + final trigger = switch (fault) { + 'unlink' => + 'BEFORE UPDATE OF next_preparation_id ON preparation_schedules', + 'exclusion' => 'BEFORE INSERT ON recurring_schedule_exclusions', + _ => + 'BEFORE UPDATE OF data_revision ON users WHEN EXISTS (SELECT 1 FROM recurring_schedule_exclusions)', + }; + await db.customStatement( + "CREATE TRIGGER reject_delete_graph $trigger BEGIN SELECT RAISE(ABORT, 'injected graph rollback'); END", + ); + await expectLater(aggregate.delete(intent), throwsA(anything)); + expect(await _deletionGraph(db), before); + expect( + await db.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + expect(await revision(), beforeRevision); + await db.customStatement('DROP TRIGGER reject_delete_graph'); + final retried = await aggregate.delete(intent); + expect(retried.changed, isTrue); + expect(await revision(), beforeRevision + 1); + expect((await db.select(db.users).getSingle()).eligibleOutcomeCount, 3); + expect((await db.select(db.users).getSingle()).onTimeOutcomeCount, 2); + expect( + await db.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + }, + ); + } + + test( + 'file database reopen and materialization preserve the deleted slot and original count', + () async { + final directory = await Directory.systemTemp.createTemp( + 'u01-recurring-reopen-', + ); + final file = File('${directory.path}/recurrence.sqlite'); + var disk = AppDatabase.forTesting(NativeDatabase(file)); + var opened = true; + final diskGate = LocalDataOperationGate(); + try { + await disk.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final writer = RecurringScheduleRepositoryImpl(disk, now: () => now); + final aggregateWriter = ScheduleAggregateRepositoryImpl( + disk, + writer, + gate: diskGate, + now: () => now, + ); + await writer.create( + _schedule('persisted-series', start), + _prep('persisted'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 5, + ), + ); + final initial = + (await disk.scheduleDao.getScheduleList()) + .map((v) => v.toScheduleEntity()) + .toList() + ..sort( + (a, b) => a.recurringOrdinal!.compareTo(b.recurringOrdinal!), + ); + expect(initial, hasLength(5)); + final target = initial[2]; + final remaining = initial.where((v) => v.id != target.id).toList(); + await aggregateWriter.delete( + await aggregateWriter.readForDeletion(target.id), + ); + final committedRevision = + (await disk.select(disk.users).getSingle()).dataRevision; + final ruleBefore = + (await disk.select(disk.recurringScheduleSegments).getSingle()) + .ruleJson; + await disk.close(); + opened = false; + disk = AppDatabase.forTesting(NativeDatabase(file)); + opened = true; + final reopened = RecurringScheduleRepositoryImpl(disk, now: () => now); + await reopened.materialize(start, DateTime.utc(2032)); + final rows = (await disk.scheduleDao.getScheduleList()) + .map((v) => v.toScheduleEntity()) + .toList(); + expect( + rows.map((v) => v.id).toSet(), + remaining.map((v) => v.id).toSet(), + ); + expect( + rows.map((v) => v.recurringOrdinal).toSet(), + remaining.map((v) => v.recurringOrdinal).toSet(), + ); + expect( + rows.any((v) => v.recurringSlotKey == target.recurringSlotKey), + isFalse, + ); + expect(rows, hasLength(4)); + final exclusion = await disk + .select(disk.recurringScheduleExclusions) + .getSingle(); + expect(exclusion.segmentId, target.recurringSegmentId); + expect(exclusion.slotKey, target.recurringSlotKey); + expect(exclusion.ordinal, target.recurringOrdinal); + expect( + (await disk.select(disk.recurringScheduleSegments).getSingle()) + .ruleJson, + ruleBefore, + ); + expect( + (await disk.select(disk.users).getSingle()).dataRevision, + committedRevision, + ); + expect( + await disk.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + } finally { + if (opened) { + await disk.close(); + } + diskGate.dispose(); + await directory.delete(recursive: true); + } + }, + ); + + test( + 'completed recurring occurrence is deleted once and exclusion prevents materialization', + () async { + await recurring.create( + _schedule('series', start), + _prep('series'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 3, + ), + ); + final rows = await db.scheduleDao.getScheduleList(); + final id = rows[1].schedule.id; + await (db.update(db.schedules)..where((t) => t.id.equals(id))).write( + const SchedulesCompanion( + doneStatus: Value('normalEnd'), + preparationFrozen: Value(true), + ), + ); + final intent = await aggregate.readForDeletion(id); + final before = await revision(); + await aggregate.delete(intent); + expect(await revision(), before + 1); + await recurring.materialize(start, DateTime.utc(2031)); + expect( + (await db.select(db.schedules).get()).map((row) => row.id), + isNot(contains(id)), + ); + expect( + await db.select(db.recurringScheduleExclusions).get(), + hasLength(1), + ); + await aggregate.delete(intent); + expect(await revision(), before + 1); + }, + ); + + test( + 'following confirmation rejects new materialized targets instead of widening deletion', + () async { + await recurring.create( + _schedule('series', start), + _prep('series'), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 65, + ), + ); + final row = (await db.scheduleDao.getScheduleList()).first; + final intent = await aggregate.readForDeletion( + row.schedule.id, + scope: RecurringEditScope.following, + ); + expect(intent.targets, hasLength(60)); + await recurring.materialize(start, DateTime.utc(2031)); + await expectLater( + aggregate.delete(intent), + rejected(ScheduleDeletionFailure.conflict), + ); + expect(await db.select(db.schedules).get(), hasLength(65)); + }, + ); +} + +ScheduleEntity _schedule(String id, DateTime at) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Place'), + scheduleName: id, + scheduleTime: at, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: true, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); +PreparationEntity _prep(String id) => PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: '$id-step-1', + preparationName: 'First', + preparationTime: const Duration(minutes: 1), + ), + PreparationStepEntity( + id: '$id-step-2', + preparationName: 'Second', + preparationTime: const Duration(minutes: 1), + ), + ], +); + +Future> _deletionGraph(AppDatabase db) async { + final rows = {}; + for (final table in [ + 'users', + 'schedules', + 'preparation_schedules', + 'places', + 'preparation_definitions', + 'preparation_definition_steps', + 'recurring_schedule_segments', + 'recurring_schedule_exclusions', + ]) { + rows[table] = + (await db.customSelect('SELECT * FROM $table ORDER BY rowid').get()) + .map((row) => row.data) + .toList(); + } + return rows; +} diff --git a/test/data/repositories/schedule_restore_watch_test.dart b/test/data/repositories/schedule_restore_watch_test.dart new file mode 100644 index 00000000..9fce8493 --- /dev/null +++ b/test/data/repositories/schedule_restore_watch_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/daos/schedule_dao.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/data/tables/schedule_with_place_model.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; + +void main() { + for (final scenario in ['replace', 'pending-resume', 'rollback']) { + test( + 'old same-ID watch cannot overwrite fresh subscription after $scenario', + () async { + final db = _WatchDb(); + addTearDown(db.close); + final gate = LocalDataOperationGate.shared; + final seed = ScheduleEntity( + id: 'same', + place: const PlaceEntity(id: 'place', placeName: 'Home'), + scheduleName: 'old', + scheduleTime: DateTime(2030), + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + await db.scheduleDao.createSchedule(seed.toScheduleWithPlaceRow()); + final old = await db.scheduleDao.getScheduleById('same'); + final repository = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: _Timed(), + ); + addTearDown(repository.dispose); + final seen = []; + final subscription = repository.scheduleStream.listen((rows) { + if (rows.isNotEmpty) seen.add(rows.single.scheduleName); + }); + addTearDown(subscription.cancel); + await pumpEventQueue(); + expect(db.latches, hasLength(1)); + if (scenario == 'pending-resume') { + gate.setRecoveryPending(true); + await db.customStatement("UPDATE schedules SET schedule_name='new'"); + gate.setRecoveryPending(false); + } else if (scenario == 'rollback') { + await expectLater( + gate.run( + () => db.transaction(() async { + await db.customStatement( + "UPDATE schedules SET schedule_name='must rollback'", + ); + throw StateError('injected replacement rollback'); + }), + replacesData: true, + ), + throwsStateError, + ); + } else { + await gate.run( + () => + db.customStatement("UPDATE schedules SET schedule_name='new'"), + replacesData: true, + ); + } + await pumpEventQueue(); + expect(db.latches, hasLength(2)); + final fresh = await db.scheduleDao.getScheduleById('same'); + db.latches[1].complete([fresh]); + await pumpEventQueue(); + expect(seen.last, scenario == 'rollback' ? 'old' : 'new'); + final emissions = seen.length; + db.latches[0].complete([old]); + await pumpEventQueue(); + expect(seen, hasLength(emissions)); + expect(seen.last, scenario == 'rollback' ? 'old' : 'new'); + }, + ); + } +} + +class _WatchDb extends AppDatabase { + _WatchDb() : super.forTesting(NativeDatabase.memory()); + final latches = >>[]; + late final ScheduleDao _dao = _WatchDao(this); + @override + ScheduleDao get scheduleDao => _dao; +} + +class _WatchDao extends ScheduleDao { + _WatchDao(this.owner) : super(owner); + final _WatchDb owner; + @override + Stream> watchScheduleList() async* { + final latch = Completer>(); + owner.latches.add(latch); + yield await latch.future; + } +} + +class _Timed implements TimedPreparationRepository { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/data/repositories/schedule_start_transaction_test.dart b/test/data/repositories/schedule_start_transaction_test.dart index 850d02da..08786432 100644 --- a/test/data/repositories/schedule_start_transaction_test.dart +++ b/test/data/repositories/schedule_start_transaction_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; @@ -123,6 +124,10 @@ void main() { ), ], ), + timeResolution: ScheduleTimeResolver.resolve( + await repository.getScheduleById('one'), + nowUtc: DateTime.now().toUtc(), + ), ); final skip = PreparationActionEventEntity.skipStep( stepId: 'step', diff --git a/test/data/repositories/schedule_time_correction_repository_test.dart b/test/data/repositories/schedule_time_correction_repository_test.dart new file mode 100644 index 00000000..c8602f93 --- /dev/null +++ b/test/data/repositories/schedule_time_correction_repository_test.dart @@ -0,0 +1,630 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_time_correction_repository_impl.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:timezone/timezone.dart' as tz; + +const _zone = 'Test/A10_Correction_Owned'; + +void main() { + late Directory directory; + late AppDatabase db; + late LocalDataOperationGate gate; + late ScheduleAggregateRepositoryImpl aggregates; + late ScheduleTimeCorrectionRepositoryImpl repository; + late DateTime now; + late Map originalRules; + late tz.Location originalLocal; + + void rules(int seconds) { + tz.timeZoneDatabase.locations[_zone] = tz.Location( + _zone, + [], + [], + [tz.TimeZone(seconds * 1000, isDst: false, abbreviation: 'QA')], + ); + } + + Future open() async { + db = AppDatabase.forTesting( + NativeDatabase(File('${directory.path}/app.sqlite')), + ); + aggregates = ScheduleAggregateRepositoryImpl( + db, + RecurringScheduleRepositoryImpl(db, now: () => now), + gate: gate, + now: () => now, + ); + repository = ScheduleTimeCorrectionRepositoryImpl( + db, + aggregates, + gate: gate, + now: () => now, + ); + } + + setUp(() async { + TimeZoneRules.ensureInitialized(); + originalRules = Map.of(tz.timeZoneDatabase.locations); + originalLocal = tz.local; + rules(0); + now = DateTime.utc(2030, 1, 1, 8); + gate = LocalDataOperationGate(); + directory = await Directory.systemTemp.createTemp('ontime-a10-correction-'); + await open(); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'Retain profile data', + isOnboardingCompleted: true, + ), + ); + }); + + tearDown(() async { + await db.close(); + gate.dispose(); + await directory.delete(recursive: true); + tz.timeZoneDatabase.locations + ..clear() + ..addAll(originalRules); + tz.setLocalLocation(originalLocal); + }); + + Future seed({DateTime? civil}) async { + await aggregates.save( + ScheduleFormSubmission( + schedule: ScheduleEntity( + id: 'appointment', + place: const PlaceEntity(id: 'office', placeName: 'Original office'), + scheduleName: 'Original appointment', + scheduleTime: civil ?? DateTime.utc(2030, 1, 1, 10, 0, 3, 123, 456), + timeZoneId: _zone, + occurrenceOffsetSeconds: 0, + moveTime: const Duration(minutes: 15), + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: 'Do not replace this note', + isChanged: true, + isStarted: false, + preparationMode: SchedulePreparationMode.custom, + ), + preparation: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'original-step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 10), + ), + ], + ), + preparationChanged: true, + baseline: await aggregates.newBaseline(), + mutationId: 'create-original', + ), + editing: false, + ); + rules(3600); + } + + Future> row() async => + (await db.customSelect('SELECT * FROM schedules').getSingle()).data; + Future> profile() async => + (await db.customSelect('SELECT * FROM users').getSingle()).data; + + // Read all persistent user tables, including authority and receipt columns. + Future>> databaseContents() async { + final tables = await db + .customSelect( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .get(); + final result = >{}; + for (final table in tables) { + final name = table.read('name'); + final rows = await db.customSelect('SELECT * FROM "$name"').get(); + result[name] = rows.map((r) => jsonEncode(r.data)).toList()..sort(); + } + return result; + } + + ScheduleTimeCorrectionCommand command( + ScheduleTimeCorrectionReview review, { + DateTime? civil, + String zone = _zone, + int offset = 3600, + String mutation = 'correct-time', + }) => ScheduleTimeCorrectionCommand( + review: review, + civil: CivilDateTime.fromFields( + civil ?? review.snapshot.schedule.scheduleTime, + ), + timeZoneId: zone, + offsetSeconds: offset, + mutationId: mutation, + ); + + final rejected = throwsA(isA()); + final conflict = throwsA( + isA().having( + (error) => error.failure, + 'failure', + ScheduleSaveFailure.conflict, + ), + ); + + test( + 'review is read-only and correction changes only time and one atomic receipt', + () async { + now = DateTime.utc(2030, 1, 1, 7); + await seed(civil: DateTime.utc(2030, 1, 1, 8, 20, 3, 123, 456)); + now = DateTime.utc(2030, 1, 1, 8); + final before = await row(); + final beforeProfile = await profile(); + final contents = await databaseContents(); + final review = await repository.review('appointment'); + expect(review.resolution.status, ScheduleTimeResolutionStatus.changed); + expect( + review.snapshot.preparation.totalDuration, + const Duration(minutes: 10), + ); + expect(await databaseContents(), contents); + // Ordinary editing stays protected because its old preparation start passed. + await expectLater( + aggregates.save( + ScheduleFormSubmission( + schedule: review.snapshot.schedule.copyWith( + scheduleName: 'Forbidden rename', + ), + preparation: review.snapshot.preparation, + preparationChanged: false, + originalSchedule: review.snapshot.schedule, + baseline: review.snapshot.baseline, + mutationId: 'ordinary-edit', + ), + editing: true, + ), + rejected, + ); + expect(await databaseContents(), contents); + + final target = DateTime.utc(2030, 1, 1, 11, 0, 3, 123, 456); + final receipt = await repository.confirm(command(review, civil: target)); + expect(receipt.scheduleId, 'appointment'); + expect(receipt.changed, isTrue); + expect(repository.isCurrent(receipt), isTrue); + final after = await row(); + final snapshot = await aggregates.readForEdit('appointment'); + expect(snapshot.schedule.scheduleTime, target); + expect(snapshot.schedule.occurrenceOffsetSeconds, 3600); + expect( + snapshot.schedule.occurrenceInstantUtc, + DateTime.utc(2030, 1, 1, 10, 0, 3, 123, 456), + ); + expect(snapshot.preparation, review.snapshot.preparation); + final allowed = { + 'schedule_time', + 'time_zone_id', + 'occurrence_offset_seconds', + 'is_changed', + 'aggregate_version', + 'last_mutation_id', + 'last_mutation_digest', + 'last_mutation_version', + }; + for (final key in before.keys.where((key) => !allowed.contains(key))) { + expect( + after[key], + before[key], + reason: 'Non-time field $key must survive', + ); + } + expect( + after['aggregate_version'], + (before['aggregate_version'] as int) + 1, + ); + expect(after['last_mutation_id'], 'correct-time'); + expect(after['last_mutation_version'], after['aggregate_version']); + expect( + (await profile())['data_revision'], + (beforeProfile['data_revision'] as int) + 1, + ); + final afterContents = await databaseContents(); + for (final table in contents.keys.where( + (t) => t != 'schedules' && t != 'users', + )) { + expect( + afterContents[table], + contents[table], + reason: '$table is not a time correction target', + ); + } + }, + ); + + test( + 'a revision write fault rolls back all tables and the same command can retry', + () async { + await seed(); + final intent = command(await repository.review('appointment')); + final before = await databaseContents(); + await db.customStatement( + "CREATE TRIGGER reject_correction_revision BEFORE UPDATE OF data_revision ON users BEGIN SELECT RAISE(ABORT, 'a10 correction revision failure'); END", + ); + await expectLater( + repository.confirm(intent), + throwsA( + predicate( + (error) => + error.toString().contains('a10 correction revision failure'), + ), + ), + ); + expect(await databaseContents(), before); + await db.customStatement('DROP TRIGGER reject_correction_revision'); + expect((await repository.confirm(intent)).changed, isTrue); + final committed = await databaseContents(); + expect((await repository.confirm(intent)).changed, isFalse); + expect(await databaseContents(), committed); + }, + ); + + test( + 'concurrent and reopened retries preserve one version and original receipt', + () async { + await seed(); + final before = await row(); + final beforeProfile = await profile(); + final intent = command(await repository.review('appointment')); + final receipts = await Future.wait([ + repository.confirm(intent), + repository.confirm(intent), + ]); + expect(receipts.where((value) => value.changed), hasLength(1)); + expect( + (await row())['aggregate_version'], + (before['aggregate_version'] as int) + 1, + ); + expect( + (await profile())['data_revision'], + (beforeProfile['data_revision'] as int) + 1, + ); + final committed = await databaseContents(); + await db.close(); + await open(); + now = DateTime.utc( + 2031, + ); // An exact committed retry cannot create another run or rewrite history. + expect((await repository.confirm(intent)).changed, isFalse); + expect(await databaseContents(), committed); + }, + ); + + test( + 'one mutation cannot authorize a different payload or ordinary edit', + () async { + await seed(); + final review = await repository.review('appointment'); + final intent = command(review); + await repository.confirm(intent); + final before = await databaseContents(); + await expectLater( + repository.confirm( + command(review, civil: DateTime.utc(2030, 1, 1, 12)), + ), + conflict, + ); + final current = await aggregates.readForEdit('appointment'); + await expectLater( + aggregates.save( + ScheduleFormSubmission( + schedule: current.schedule.copyWith( + scheduleName: 'Different operation', + ), + preparation: current.preparation, + preparationChanged: false, + originalSchedule: current.schedule, + baseline: current.baseline, + mutationId: intent.mutationId, + ), + editing: true, + ), + conflict, + ); + expect(await databaseContents(), before); + }, + ); + + for (final race in [ + 'version', + 'incarnation', + 'store', + 'generation', + 'material', + 'rules', + 'clock rewind', + 'preparation deadline', + 'occurrence deadline', + ]) { + test( + '$race invalidates review without overwriting concurrent data', + () async { + await seed(civil: DateTime.utc(2030, 1, 1, 10)); + final intent = command(await repository.review('appointment')); + switch (race) { + case 'version': + await db.customStatement( + 'UPDATE schedules SET aggregate_version=aggregate_version+1', + ); + case 'incarnation': + await db.customStatement( + "UPDATE schedules SET aggregate_incarnation='replacement-incarnation'", + ); + case 'store': + await db.customStatement( + "UPDATE users SET store_incarnation='replacement-store'", + ); + case 'generation': + await gate.run(() async {}, replacesData: true); + case 'material': + await db.transaction(() async { + await db.preparationScheduleDao.createPreparationSchedule( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'new-step', + preparationName: 'Longer preparation', + preparationTime: Duration(minutes: 90), + ), + ], + ), + 'appointment', + ); + await db.userDao.markDurableDataChanged('local-profile'); + }); + case 'rules': + rules(7200); + case 'clock rewind': + now = now.subtract(const Duration(microseconds: 1)); + case 'preparation deadline': + now = DateTime.utc(2030, 1, 1, 8, 30); + case 'occurrence deadline': + now = DateTime.utc(2030, 1, 1, 9); + } + final current = await databaseContents(); + await expectLater(repository.confirm(intent), rejected); + expect(await databaseContents(), current); + }, + ); + } + + for (final invalid in [ + 'unknown zone', + 'wrong offset', + 'gap', + 'past target', + 'blank mutation', + ]) { + test('$invalid is refused without replacing the reviewed row', () async { + await seed(); + final review = await repository.review('appointment'); + final intent = switch (invalid) { + 'unknown zone' => command(review, zone: 'Missing/Zone'), + 'wrong offset' => command(review, offset: 7200), + 'gap' => command( + review, + civil: DateTime.utc(2030, 3, 10, 2, 30), + zone: 'America/New_York', + offset: -18000, + ), + 'past target' => command( + review, + civil: DateTime.utc(2029), + zone: 'UTC', + offset: 0, + ), + _ => command(review, mutation: ''), + }; + final before = await databaseContents(); + await expectLater(repository.confirm(intent), rejected); + expect(await databaseContents(), before); + }); + } + + test( + 'a diagnostic review cannot forge correction authority for a resolved row', + () async { + await seed(); + rules(0); + final review = await repository.review('appointment'); + expect(review.resolution.status, ScheduleTimeResolutionStatus.resolved); + final forged = ScheduleTimeCorrectionReview( + snapshot: review.snapshot, + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.changed, + ), + ruleIdentity: review.ruleIdentity, + validationNowUtc: review.validationNowUtc, + ); + final before = await databaseContents(); + await expectLater( + repository.confirm(command(forged, zone: 'UTC', offset: 0)), + rejected, + ); + expect(await databaseContents(), before); + }, + ); + + for (final gateState in ['recovery', 'invalidated']) { + test( + '$gateState blocks a previously valid command without changing data', + () async { + await seed(); + final intent = command(await repository.review('appointment')); + if (gateState == 'recovery') { + gate.setRecoveryPending(true); + } else { + gate.invalidate(); + } + final before = await databaseContents(); + await expectLater(repository.confirm(intent), rejected); + expect(await databaseContents(), before); + }, + ); + } + + for (final problem in ['unknown', 'gap', 'overlap']) { + test( + '$problem requires an explicit valid occurrence and then saves that exact choice', + () async { + now = DateTime.utc(2026, 1, 1); + await seed(); + // Representative admitted legacy active rows. These raw fixtures are not + // claimed to be produced by the normal new-schedule form. + final oldCivil = problem == 'gap' + ? DateTime.utc(2026, 3, 8, 2, 30) + : problem == 'overlap' + ? DateTime.utc(2026, 11, 1, 1, 30) + : DateTime.utc(2030, 1, 1, 10); + final oldZone = problem == 'unknown' + ? 'Missing/Zone' + : 'America/New_York'; + await db.customStatement( + 'UPDATE schedules SET schedule_time=?, time_zone_id=?, occurrence_offset_seconds=NULL', + [CivilDateTime.fromFields(oldCivil).toCivilIso8601String(), oldZone], + ); + final review = await repository.review('appointment'); + expect(review.resolution.status, switch (problem) { + 'unknown' => ScheduleTimeResolutionStatus.unknownZone, + 'gap' => ScheduleTimeResolutionStatus.nonexistent, + _ => ScheduleTimeResolutionStatus.ambiguous, + }); + expect(review.blockedBy, isNull); + final target = problem == 'gap' + ? DateTime.utc(2026, 3, 8, 3, 30) + : oldCivil; + final offset = problem == 'unknown' + ? 0 + : problem == 'gap' + ? -14400 + : -18000; + await repository.confirm( + command( + review, + civil: target, + zone: problem == 'unknown' ? 'UTC' : 'America/New_York', + offset: offset, + ), + ); + final saved = (await aggregates.readForEdit('appointment')).schedule; + expect(saved.scheduleTime, target); + expect(saved.occurrenceOffsetSeconds, offset); + expect(saved.occurrenceInstantUtc, switch (problem) { + 'unknown' => DateTime.utc(2030, 1, 1, 10), + 'gap' => DateTime.utc(2026, 3, 8, 7, 30), + _ => DateTime.utc(2026, 11, 1, 6, 30), + }); + expect(saved.isStarted, isFalse); + expect(saved.startedAt, isNull); + }, + ); + } + + for (final facts in [ + 'frozen', + 'started', + 'completed', + 'scored', + 'past explicit', + 'past null', + ]) { + test( + '$facts remains protected even if the review claims a repairable time', + () async { + await seed(); + // Defensive persisted facts, including independently protected legacy + // markers; no claim that an incomplete marker set is a valid backup. + switch (facts) { + case 'frozen': + await db.customStatement( + 'UPDATE schedules SET preparation_frozen=1', + ); + case 'started': + await db.customStatement( + 'UPDATE schedules SET is_started=1, preparation_frozen=1, started_at=?', + [now.millisecondsSinceEpoch ~/ 1000], + ); + case 'completed': + await db.customStatement( + "UPDATE schedules SET done_status='normalEnd', finished_at=?", + [now.millisecondsSinceEpoch ~/ 1000], + ); + case 'scored': + await db.customStatement( + 'UPDATE schedules SET score_contribution_recorded=1', + ); + case 'past explicit': + now = DateTime.utc(2031); + case 'past null': + await db.customStatement( + 'UPDATE schedules SET occurrence_offset_seconds=NULL', + ); + now = DateTime.utc(2031); + } + final review = await repository.review('appointment'); + expect(review.blockedBy, ScheduleSaveFailure.protected); + final forged = ScheduleTimeCorrectionReview( + snapshot: review.snapshot, + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.changed, + ), + ruleIdentity: review.ruleIdentity, + validationNowUtc: review.validationNowUtc, + ); + final before = await databaseContents(); + await expectLater( + repository.confirm( + command(forged, civil: DateTime.utc(2032), zone: 'UTC', offset: 0), + ), + rejected, + ); + expect(await databaseContents(), before); + }, + ); + } + + test( + 'a committed receipt loses authority on recovery or data replacement', + () async { + await seed(); + final receipt = await repository.confirm( + command(await repository.review('appointment')), + ); + expect(repository.isCurrent(receipt), isTrue); + gate.setRecoveryPending(true); + expect(repository.isCurrent(receipt), isFalse); + gate.setRecoveryPending(false); + expect(repository.isCurrent(receipt), isTrue); + await gate.run(() async {}, replacesData: true); + expect(repository.isCurrent(receipt), isFalse); + }, + ); +} diff --git a/test/data/repositories/schedule_time_review_commit_test.dart b/test/data/repositories/schedule_time_review_commit_test.dart new file mode 100644 index 00000000..26a50f9d --- /dev/null +++ b/test/data/repositories/schedule_time_review_commit_test.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'schedule_aggregate_repository_test.dart' show prep, schedule; + +class _BeforeCommitReceipt extends QueryInterceptor { + bool armed = false; + final entered = Completer(); + final release = Completer(); + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await executor.runUpdate(statement, args); + if (armed && statement.contains('last_mutation_id')) { + armed = false; + entered.complete(); + await release.future; + } + return result; + } +} + +void main() { + for (final change in ['clock', 'rules', 'draft authority']) { + test( + 'actual SQLite transaction rolls back when $change changes after receipt write', + () async { + final barrier = _BeforeCommitReceipt(); + final db = AppDatabase.forTesting( + NativeDatabase.memory().interceptWith(barrier), + ); + addTearDown(db.close); + final gate = LocalDataOperationGate(); + var now = DateTime.utc(2029); + var ownsDraft = true; + final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + final repository = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final baseline = await repository.newBaseline(); + final rules = TimeZoneRules.loadedIdentity; + final reviewedAt = now; + const syntheticZone = 'Test/U02_Commit_Barrier'; + addTearDown(() => tz.timeZoneDatabase.locations.remove(syntheticZone)); + final value = ScheduleFormSubmission( + schedule: schedule(), + preparation: prep(), + preparationChanged: true, + baseline: baseline, + mutationId: 'reviewed-intent', + validateTimeReview: () { + if (!ownsDraft || + now != reviewedAt || + TimeZoneRules.loadedIdentity != rules) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.conflict); + } + }, + ); + barrier.armed = true; + final saving = repository.save(value, editing: false); + final failure = expectLater( + saving, + throwsA(isA()), + ); + await barrier.entered.future; + if (change == 'clock') now = now.add(const Duration(days: 400)); + if (change == 'draft authority') ownsDraft = false; + if (change == 'rules') { + tz.timeZoneDatabase.locations[syntheticZone] = tz.Location( + syntheticZone, + [], + [], + [const tz.TimeZone(3600000, isDst: false, abbreviation: 'U02')], + ); + } + barrier.release.complete(); + await failure; + expect(await db.select(db.schedules).get(), isEmpty); + expect(await db.select(db.places).get(), isEmpty); + expect(await db.select(db.preparationSchedules).get(), isEmpty); + expect( + (await db.select(db.users).getSingle()).dataRevision, + baseline.revision, + ); + tz.timeZoneDatabase.locations.remove(syntheticZone); + now = reviewedAt; + ownsDraft = true; + await repository.save(value, editing: false); + expect(await db.select(db.schedules).get(), hasLength(1)); + expect( + (await db.select(db.users).getSingle()).dataRevision, + baseline.revision + 1, + ); + }, + ); + } + + test( + 'reviewed edit rejects profile revision changed after review; fresh review can save', + () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final repository = ScheduleAggregateRepositoryImpl( + db, + RecurringScheduleRepositoryImpl(db, now: () => DateTime.utc(2029)), + gate: LocalDataOperationGate(), + now: () => DateTime.utc(2029), + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + await repository.save( + ScheduleFormSubmission( + schedule: schedule(), + preparation: prep(), + preparationChanged: true, + baseline: await repository.newBaseline(), + mutationId: 'create', + ), + editing: false, + ); + final reviewed = await repository.readForEdit('one'); + final proposed = reviewed.schedule.copyWith( + scheduleTime: DateTime.utc(2030, 1, 2, 10), + ); + ScheduleFormSubmission submission(ScheduleEditBaseline baseline) => + ScheduleFormSubmission( + schedule: proposed, + preparation: reviewed.preparation, + preparationChanged: false, + originalSchedule: reviewed.schedule, + baseline: baseline, + mutationId: 'reviewed-edit', + validateTimeReview: () {}, + ); + // The Bloc has checked the current baseline, then another settings write + // commits before the aggregate transaction actually reads its profile. + final justChecked = await repository.newBaseline(); + expect(justChecked.revision, reviewed.baseline.revision); + await db.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 3), + ); + final beforeRow = await db.select(db.schedules).getSingle(); + final beforeRevision = + (await db.select(db.users).getSingle()).dataRevision; + await expectLater( + repository.save(submission(reviewed.baseline), editing: true), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + ScheduleSaveFailure.conflict, + ), + ), + ); + expect(await db.select(db.schedules).getSingle(), beforeRow); + expect( + (await db.select(db.users).getSingle()).dataRevision, + beforeRevision, + ); + final rereviewed = await repository.readForEdit('one'); + final saved = await repository.save( + submission(rereviewed.baseline), + editing: true, + ); + expect(saved.changed, isTrue); + expect( + (await repository.readForEdit('one')).schedule.scheduleTime, + proposed.scheduleTime, + ); + expect( + (await db.select(db.users).getSingle()).dataRevision, + beforeRevision + 1, + ); + }, + ); + + test( + 'ordinary name edit retains existing policy after unrelated profile revision', + () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final repository = ScheduleAggregateRepositoryImpl( + db, + RecurringScheduleRepositoryImpl(db, now: () => DateTime.utc(2029)), + gate: LocalDataOperationGate(), + now: () => DateTime.utc(2029), + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + await repository.save( + ScheduleFormSubmission( + schedule: schedule(), + preparation: prep(), + preparationChanged: true, + baseline: await repository.newBaseline(), + mutationId: 'create', + ), + editing: false, + ); + final original = await repository.readForEdit('one'); + await db.userDao.updateSpareTime( + 'local-profile', + const Duration(minutes: 3), + ); + final saved = await repository.save( + ScheduleFormSubmission( + schedule: original.schedule.copyWith(scheduleName: 'Renamed'), + preparation: original.preparation, + preparationChanged: false, + originalSchedule: original.schedule, + baseline: original.baseline, + mutationId: 'rename', + ), + editing: true, + ); + expect(saved.changed, isTrue); + expect( + (await repository.readForEdit('one')).schedule.scheduleName, + 'Renamed', + ); + }, + ); + + test( + 'lost successful response replays receipt without repeating expired time approval', + () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final repository = ScheduleAggregateRepositoryImpl( + db, + RecurringScheduleRepositoryImpl(db, now: () => DateTime.utc(2029)), + gate: LocalDataOperationGate(), + now: () => DateTime.utc(2029), + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final baseline = await repository.newBaseline(); + var approved = true, validations = 0; + final value = ScheduleFormSubmission( + schedule: schedule(), + preparation: prep(), + preparationChanged: true, + baseline: baseline, + mutationId: 'lost-response', + validateTimeReview: () { + validations++; + if (!approved) { + throw const ScheduleSaveRejected(ScheduleSaveFailure.conflict); + } + }, + ); + await repository.save( + value, + editing: false, + ); // The response is intentionally not retained. + final firstValidations = validations; + approved = false; + final replay = await repository.save(value, editing: false); + expect(replay.changed, isFalse); + expect(replay.mutationId, 'lost-response'); + expect(validations, firstValidations); + expect(await db.select(db.schedules).get(), hasLength(1)); + expect( + (await db.select(db.users).getSingle()).dataRevision, + baseline.revision + 1, + ); + }, + ); +} diff --git a/test/data/repositories/single_time_correction_conflict_test.dart b/test/data/repositories/single_time_correction_conflict_test.dart new file mode 100644 index 00000000..c1326bd1 --- /dev/null +++ b/test/data/repositories/single_time_correction_conflict_test.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/repositories/schedule_time_correction_repository.dart'; +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:on_time_front/domain/use-cases/schedule_time_correction_workflow.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_time_correction_screen.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'dart:convert'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_time_correction_repository_impl.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; + +void main() { + const zone = 'Test/A10_Conflict_Target'; + const otherZone = 'Test/A10_Conflict_Other'; + late AppDatabase db; + late LocalDataOperationGate gate; + late ScheduleAggregateRepositoryImpl aggregates; + late ScheduleTimeCorrectionRepositoryImpl repository; + late Map original; + final now = DateTime.utc(2030, 1, 1); + void rules(String id, int seconds) => + tz.timeZoneDatabase.locations[id] = tz.Location(id, [], [], [ + tz.TimeZone(seconds * 1000, isDst: false, abbreviation: 'QA'), + ]); + ScheduleEntity row( + String id, + DateTime civil, { + String? inZone, + int offset = 0, + }) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: id), + scheduleName: id, + scheduleTime: civil, + timeZoneId: inZone ?? zone, + occurrenceOffsetSeconds: offset, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Preserve', + isChanged: false, + isStarted: false, + ); + setUp(() async { + TimeZoneRules.ensureInitialized(); + original = Map.of(tz.timeZoneDatabase.locations); + rules(zone, 0); + rules(otherZone, 0); + db = AppDatabase.forTesting(NativeDatabase.memory()); + gate = LocalDataOperationGate(); + final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + aggregates = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + repository = ScheduleTimeCorrectionRepositoryImpl( + db, + aggregates, + gate: gate, + now: () => now, + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + await aggregates.save( + ScheduleFormSubmission( + schedule: row('target', DateTime.utc(2030, 1, 2, 10)), + preparation: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'ready', + preparationName: 'Ready', + preparationTime: Duration(minutes: 10), + ), + ], + ), + preparationChanged: true, + baseline: await aggregates.newBaseline(), + mutationId: 'create-target', + ), + editing: false, + ); + rules(zone, 3600); + }); + tearDown(() async { + await db.close(); + gate.dispose(); + tz.timeZoneDatabase.locations + ..clear() + ..addAll(original); + }); + Future> contents() async => [ + for (final table in ['schedules', 'users', 'preparation_schedules']) + for (final row + in await db.customSelect('SELECT * FROM $table ORDER BY id').get()) + jsonEncode(row.data), + ]; + Future command({ + int hour = 10, + Set acknowledge = const {}, + }) async => ScheduleTimeCorrectionCommand( + review: await repository.review('target'), + civil: CivilDateTime.fromFields(DateTime.utc(2030, 1, 2, hour)), + timeZoneId: zone, + offsetSeconds: 3600, + mutationId: 'correct-target', + acknowledgedUncertainIds: acknowledge, + ); + + testWidgets( + 'actual commit with lost response replays the identical UI intent', + (tester) async { + final proxy = _LostResponse(repository); + final effects = _Effects(); + var saved = 0; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ScheduleTimeCorrectionScreen( + scheduleId: 'target', + workflow: ScheduleTimeCorrectionWorkflow(proxy, effects), + onSaved: () => saved++, + ), + ), + ), + ); + await tester.runAsync(() => proxy.pending); + await tester.pumpAndSettle(); + Future submit() async { + final review = find.byKey(const Key('review-time-correction')); + await tester.scrollUntilVisible(review, 300); + await tester.tap(review); + await tester.runAsync(() => proxy.pending); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('confirm-time-correction')), + findsOneWidget, + ); + await tester.tap(find.byKey(const Key('confirm-time-correction'))); + await tester.runAsync(() => proxy.pending); + await tester.pumpAndSettle(); + } + + await submit(); + expect(saved, 0); + expect(proxy.commands, hasLength(1)); + final committed = await tester.runAsync(contents); + expect(proxy.choiceReviews, 1); + await submit(); + expect(saved, 1); + expect(proxy.commands, hasLength(2)); + expect(identical(proxy.commands.first, proxy.commands.last), isTrue); + expect(proxy.choiceReviews, 1); + expect(await tester.runAsync(contents), committed); + expect(effects.calls, 1); + expect(tester.takeException(), isNull); + }, + ); + + test( + 'same instant with a different civil date/zone blocks atomically until time is reselected', + () async { + await db.scheduleDao.createSchedule( + row( + 'other', + DateTime.utc(2030, 1, 2, 18), + inZone: 'Asia/Seoul', + offset: 32400, + ).toScheduleWithPlaceRow(), + ); + final choice = await command(); + final before = await contents(); + final proof = await repository.reviewChoice(choice); + expect(proof.conflicts, hasLength(1)); + expect(proof.conflicts.single.other.schedule.id, 'other'); + expect( + proof.conflicts.single.slot.instantUtc, + DateTime.utc(2030, 1, 2, 9), + ); + expect(await contents(), before); + await expectLater( + repository.confirm(choice), + throwsA(isA()), + ); + expect(await contents(), before); + final selected = await command(hour: 12); + expect((await repository.reviewChoice(selected)).conflicts, isEmpty); + expect((await repository.confirm(selected)).changed, isTrue); + }, + ); + test( + 'possible future uncertainty requires exact acknowledgement and preserves the other record', + () async { + await db.scheduleDao.createSchedule( + row( + 'uncertain', + DateTime.utc(2030, 1, 2, 10), + inZone: otherZone, + ).toScheduleWithPlaceRow(), + ); + tz.timeZoneDatabase.locations.remove(otherZone); + final choice = await command(); + final before = await contents(); + final proof = await repository.reviewChoice(choice); + expect(proof.conflicts, isEmpty); + expect(proof.possibleOverlaps.map((e) => e.id), ['uncertain']); + await expectLater( + repository.confirm(choice), + throwsA(isA()), + ); + expect(await contents(), before); + final otherBefore = + (await db + .customSelect("SELECT * FROM schedules WHERE id='uncertain'") + .getSingle()) + .data; + expect( + (await repository.confirm( + await command(acknowledge: {'uncertain'}), + )).changed, + isTrue, + ); + expect( + (await db + .customSelect("SELECT * FROM schedules WHERE id='uncertain'") + .getSingle()) + .data, + otherBefore, + ); + }, + ); + test( + 'a distant unresolved record does not become a blanket correction block', + () async { + await db.scheduleDao.createSchedule( + row( + 'distant', + DateTime.utc(2040, 1, 1), + inZone: otherZone, + ).toScheduleWithPlaceRow(), + ); + tz.timeZoneDatabase.locations.remove(otherZone); + final choice = await command(); + final proof = await repository.reviewChoice(choice); + expect(proof.possibleOverlaps, isEmpty); + expect((await repository.confirm(choice)).changed, isTrue); + }, + ); +} + +class _LostResponse implements ScheduleTimeCorrectionRepository { + _LostResponse(this.actual); + final ScheduleTimeCorrectionRepository actual; + Future pending = Future.value(); + Future track(Future value) { + pending = value.then((_) {}, onError: (Object _, StackTrace __) {}); + return value; + } + + final commands = []; + int choiceReviews = 0; + @override + Future review(String id) async => + await track(actual.review(id)); + @override + Future reviewChoice( + ScheduleTimeCorrectionCommand command, + ) async { + choiceReviews++; + return await track(actual.reviewChoice(command)); + } + + @override + Future confirm( + ScheduleTimeCorrectionCommand command, + ) async { + commands.add(command); + final receipt = await track(actual.confirm(command)); + if (commands.length == 1) { + throw StateError('response lost after actual commit'); + } + return receipt; + } + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => actual.isCurrent(receipt); +} + +class _Effects extends Fake implements ScheduleMutationAlarmEffectsCoordinator { + int calls = 0; + @override + Future afterCommit() async { + calls++; + return true; + } +} diff --git a/test/data/repositories/time_correction_conflict_reader_test.dart b/test/data/repositories/time_correction_conflict_reader_test.dart new file mode 100644 index 00000000..899adf68 --- /dev/null +++ b/test/data/repositories/time_correction_conflict_reader_test.dart @@ -0,0 +1,207 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/time_correction_conflict_reader.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + late AppDatabase db; + final now = DateTime.utc(2030, 1, 1); + ScheduleEntity row(String id, {String zone = 'UTC', bool past = false}) => + ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Office'), + scheduleName: id, + scheduleTime: past ? DateTime.utc(2020) : DateTime.utc(2030, 1, 2, 10), + timeZoneId: zone, + occurrenceOffsetSeconds: zone == 'UTC' ? 0 : null, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + isChanged: false, + isStarted: false, + ); + Future read() => readTimeCorrectionConflictWorld( + db: db, + nowUtc: now, + budget: BackupBudget(), + ); + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + }); + tearDown(() => db.close()); + + test( + 'step overflow is rejected at the bounded SQL reader before proof admission', + () async { + await db.customStatement( + "INSERT INTO preparation_definitions(id,owner_id,scope,name,created_at) VALUES('def','a','occurrence','Owned',1)", + ); + await db.transaction(() async { + for (var i = 0; i <= BackupLimits.preparationSteps; i++) { + await db.customStatement( + 'INSERT INTO preparation_definition_steps(id,definition_id,name,minutes,position) VALUES(?,?,?,?,?)', + ['step-$i', 'def', 'Ready', 1, i], + ); + } + }); + await db.scheduleDao.createSchedule( + row( + 'a', + ).copyWith(preparationDefinitionId: 'def').toScheduleWithPlaceRow(), + ); + await expectLater(read(), throwsA(isA())); + }, + ); + test( + 'a frozen empty captured preparation does not gain mutable default minutes', + () async { + await db.customStatement( + "INSERT INTO preparation_users(id,user_id,preparation_name,preparation_time) VALUES('default','local-profile','Default',30)", + ); + final frozen = row( + 'frozen', + ).copyWith(isStarted: true, startedAt: now, preparationFrozen: true); + await db.scheduleDao.createSchedule(frozen.toScheduleWithPlaceRow()); + await db.scheduleDao.createSchedule( + row('normal').toScheduleWithPlaceRow(), + ); + final world = await read(); + final actual = world.rows.singleWhere((e) => e.schedule.id == 'frozen'); + expect(actual.preparationStartUtc, actual.instantUtc); + final normal = world.rows.singleWhere((e) => e.schedule.id == 'normal'); + expect( + normal.instantUtc.difference(normal.preparationStartUtc), + const Duration(minutes: 30), + ); + }, + ); + test( + 'current template is used for an unfrozen row while frozen captured steps stay owned', + () async { + await db.customStatement( + "INSERT INTO preparation_templates(id,template_name,created_at,updated_at) VALUES('template','Template',1,1)", + ); + await db.customStatement( + "INSERT INTO preparation_template_steps(id,template_id,preparation_name,preparation_time,position) VALUES('template-step','template','Changed',45,0)", + ); + for (final id in ['normal', 'frozen']) { + await db.scheduleDao.createSchedule( + row(id) + .copyWith( + preparationMode: SchedulePreparationMode.template, + preparationTemplateId: 'template', + preparationTemplateName: 'Template', + preparationFrozen: id == 'frozen', + isStarted: id == 'frozen', + startedAt: id == 'frozen' ? now : null, + ) + .toScheduleWithPlaceRow(), + ); + await db.customStatement( + 'INSERT INTO preparation_schedules(id,schedule_id,preparation_name,preparation_time) VALUES(?,?,?,?)', + ['own-$id', id, 'Captured', 10], + ); + } + final world = await read(); + expect( + world.rows + .singleWhere((e) => e.schedule.id == 'normal') + .instantUtc + .difference( + world.rows + .singleWhere((e) => e.schedule.id == 'normal') + .preparationStartUtc, + ), + const Duration(minutes: 45), + ); + expect( + world.rows + .singleWhere((e) => e.schedule.id == 'frozen') + .instantUtc + .difference( + world.rows + .singleWhere((e) => e.schedule.id == 'frozen') + .preparationStartUtc, + ), + const Duration(minutes: 10), + ); + }, + ); + test( + 'future uncertainty remains separate from exact intervals and historical uncertainty', + () async { + await db.scheduleDao.createSchedule( + row('normal').toScheduleWithPlaceRow(), + ); + await db.scheduleDao.createSchedule( + row('future', zone: 'Unknown/Retired').toScheduleWithPlaceRow(), + ); + await db.scheduleDao.createSchedule( + row( + 'past', + zone: 'Unknown/Retired', + past: true, + ).toScheduleWithPlaceRow(), + ); + await db.scheduleDao.createSchedule( + row('completed', zone: 'Unknown/Retired') + .copyWith(doneStatus: ScheduleDoneStatus.normalEnd, finishedAt: now) + .toScheduleWithPlaceRow(), + ); + final world = await read(); + expect(world.rows.map((e) => e.schedule.id), ['normal']); + expect(world.unresolvedIds, {'future'}); + final possible = world.possibleOverlaps.single; + expect( + possible.mayOverlap( + DateTime.utc(2030, 1, 2), + DateTime.utc(2030, 1, 2, 12), + ), + isTrue, + ); + expect( + possible.mayOverlap(DateTime.utc(2031), DateTime.utc(2031, 1, 2)), + isFalse, + ); + }, + ); + test( + 'unknown active target keeps an open possible-impact range without fabricating an instant', + () async { + await db.scheduleDao.createSchedule( + row('active', zone: 'Unknown/Retired', past: true) + .copyWith( + isStarted: true, + startedAt: DateTime.utc(2020), + preparationFrozen: true, + ) + .toScheduleWithPlaceRow(), + ); + final world = await read(); + expect(world.rows, isEmpty); + expect(world.possibleOverlaps.single.latestTargetUtc, isNull); + expect( + world.possibleOverlaps.single.mayOverlap( + now, + now.add(const Duration(hours: 1)), + ), + isTrue, + ); + }, + ); +} diff --git a/test/data/repositories/unknown_historical_recurrence_materialization_test.dart b/test/data/repositories/unknown_historical_recurrence_materialization_test.dart new file mode 100644 index 00000000..bc595b5a --- /dev/null +++ b/test/data/repositories/unknown_historical_recurrence_materialization_test.dart @@ -0,0 +1,139 @@ +// Actual repository writer + SQLite consumer regression. The rule-zone mutation +// models unavailable historical rules; this is not an encrypted restore test. +import 'dart:convert'; +import 'package:drift/drift.dart' show Value, Variable; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; + +void main() { + const removedZone = 'Old/Removed_Test_Zone'; + const preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'source-step', + preparationName: 'Get ready', + preparationTime: Duration(minutes: 10), + ), + ], + ); + ScheduleEntity schedule(String id, DateTime at) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Office'), + scheduleName: id, + scheduleTime: at, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Keep original history', + ); + for (final queryPast in [false, true]) { + test( + 'unknown past until preserves rows and permits known future generation, past query=$queryPast', + () async { + var now = DateTime.utc(2019, 12, 31); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final repository = RecurringScheduleRepositoryImpl(db, now: () => now); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + final past = DateTime.utc(2020, 1, 1, 10); + await repository.create( + schedule('history', past), + preparation, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: past, + timeZoneId: 'UTC', + until: DateTime.utc(2020, 1, 3), + ), + ); + final history = (await repository.getSegments()).single; + now = DateTime.utc(2030, 1, 1); + final future = DateTime.utc(2030, 1, 2, 10); + await repository.create( + schedule('future', future), + preparation, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: future, + timeZoneId: 'UTC', + count: 65, + ), + ); + final known = (await repository.getSegments()).singleWhere( + (s) => s.seriesId == 'future', + ); + final segment = await (db.select( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(history.id))).getSingle(); + final rule = jsonDecode(segment.ruleJson) as Map; + final prototype = + jsonDecode(segment.scheduleJson) as Map; + rule['zone'] = removedZone; + prototype['zone'] = removedZone; + await (db.update( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(history.id))).write( + RecurringScheduleSegmentsCompanion( + ruleJson: Value(jsonEncode(rule)), + scheduleJson: Value(jsonEncode(prototype)), + ), + ); + await (db.update(db.schedules) + ..where((t) => t.recurringSegmentId.equals(history.id))) + .write(const SchedulesCompanion(timeZoneId: Value(removedZone))); + Future>> historicalRows() async => + (await db + .customSelect( + 'SELECT * FROM schedules WHERE recurring_segment_id = ? ORDER BY id', + variables: [Variable.withString(history.id)], + ) + .get()) + .map((row) => row.data) + .toList(); + Future knownCount() async => (await (db.select( + db.schedules, + )..where((t) => t.recurringSegmentId.equals(known.id))).get()).length; + final beforeRows = await historicalRows(); + final beforeSegment = await (db.select( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(history.id))).getSingle(); + expect(beforeRows, hasLength(3)); + expect(await knownCount(), 60); + await repository.materialize( + queryPast ? DateTime.utc(2019) : now, + DateTime.utc(2030, 4, 1), + ); + expect(await knownCount(), 65); + expect(await historicalRows(), beforeRows); + expect( + await (db.select( + db.recurringScheduleSegments, + )..where((t) => t.id.equals(history.id))).getSingle(), + beforeSegment, + ); + expect( + (await historicalRows()).every((row) => row['is_started'] == 0), + isTrue, + ); + }, + ); + } +} diff --git a/test/data/repositories/user_restore_identity_test.dart b/test/data/repositories/user_restore_identity_test.dart new file mode 100644 index 00000000..015350af --- /dev/null +++ b/test/data/repositories/user_restore_identity_test.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/data/data_sources/early_start_session_local_data_source.dart'; +import '../../domain/use-cases/alarm_reconciliation_concurrency_test.dart' + as concurrency; +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; +import 'package:on_time_front/data/repositories/user_repository_impl.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'first profile creation publishes its actual durable identity', + () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final identity = RestoreRuntimeIdentity.shared; + await identity.load(db); + expect(identity.storeIncarnation, isNull); + final repository = UserRepositoryImpl(db); + addTearDown(repository.dispose); + await repository.getUser(); + expect( + identity.storeIncarnation, + (await db.select(db.users).getSingle()).storeIncarnation, + ); + expect(identity.storeIncarnation, isNotNull); + SharedPreferences.setMockInitialValues({}); + await EarlyStartSessionLocalDataSourceImpl().saveSession( + scheduleId: 'first', + startedAt: DateTime.utc(2030), + ); + final prefs = await SharedPreferences.getInstance(); + expect( + jsonDecode( + prefs.getString('early_start_session_first')!, + )['storeIncarnation'], + identity.storeIncarnation, + ); + final rig = concurrency.Rig(); + addTearDown(rig.dispose); + rig.repository.schedules = [rig.schedule('first')]; + await rig.reconcile(); + expect( + rig.registry.records.single.payload['storeIncarnation'], + identity.storeIncarnation, + ); + }, + ); + test( + 'late existing profile read cannot emit old row after replacement', + () async { + final db = _BlockingDb(); + addTearDown(db.close); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'old', + ), + ); + final repository = UserRepositoryImpl(db); + addTearDown(repository.dispose); + final seen = []; + final subscription = repository.userStream.listen((u) { + if (u.valueOrNull != null) seen.add(u.valueOrNull!.note); + }); + addTearDown(subscription.cancel); + db.block = true; + final pending = repository.getUser(); + final rejected = expectLater( + pending, + throwsA(isA()), + ); + await db.entered.future; + await LocalDataOperationGate.shared.run(() async { + await db.deleteAllDurableData(); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'new', + ), + ); + }, replacesData: true); + await pumpEventQueue(); + seen.clear(); + db.release.complete(); + await rejected; + await pumpEventQueue(); + expect(seen, isNot(contains('old'))); + expect((await db.select(db.users).getSingle()).note, 'new'); + }, + ); +} + +class _BlockingDb extends AppDatabase { + _BlockingDb() : super.forTesting(NativeDatabase.memory()); + bool block = false; + final entered = Completer(), release = Completer(); + late final UserDao _blockingDao = _BlockingDao(this); + @override + UserDao get userDao => _blockingDao; +} + +class _BlockingDao extends UserDao { + _BlockingDao(this.owner) : super(owner); + final _BlockingDb owner; + @override + Future getUserById(String id) async { + final value = await super.getUserById(id); + if (owner.block) { + owner.entered.complete(); + await owner.release.future; + } + return value; + } +} diff --git a/test/domain/entities/backup_processing_test.dart b/test/domain/entities/backup_processing_test.dart new file mode 100644 index 00000000..53b113ca --- /dev/null +++ b/test/domain/entities/backup_processing_test.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; + +void main() { + test( + 'cleanup retry waits for the running operation and keeps late cleanup registrations', + () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire()..beginOperation(); + final cleaned = []; + lease.retainCleanup(() async { + cleaned.add('native'); + }); + var done = false; + final retry = owner.retryCleanup().then((_) { + done = true; + }); + await Future.delayed(Duration.zero); + expect(done, false); + expect(cleaned, isEmpty); + expect(owner.active, same(lease)); + lease.retainCleanup(() async { + cleaned.add('snapshot'); + }); + lease.endOperation(); + await retry; + expect(cleaned, ['native', 'snapshot']); + expect(owner.active, isNull); + }, + ); + test( + 'a new cleanup registered while retry is awaiting is not overwritten', + () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire(); + final entered = Completer(); + final unblock = Completer(); + final cleaned = []; + lease.retainCleanup(() async { + entered.complete(); + await unblock.future; + cleaned.add('first'); + }); + final retry = owner.retryCleanup(); + await entered.future; + lease.retainCleanup(() async { + cleaned.add('late'); + }); + unblock.complete(); + await retry; + expect(cleaned, ['first', 'late']); + expect(owner.active, isNull); + }, + ); + test( + 'failed action retains owner but other independent cleanup is still attempted', + () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire(); + var attempts = 0; + var later = 0; + lease.retainCleanup(() async { + if (++attempts == 1) throw StateError('close'); + }); + lease.retainCleanup(() async { + later++; + }); + await expectLater(owner.retryCleanup(), throwsStateError); + expect(owner.active, same(lease)); + expect(later, 1); + await owner.retryCleanup(); + expect(attempts, 2); + expect(later, 1); + expect(owner.active, isNull); + }, + ); + test( + 'release request cannot free lease while nested operations are outstanding', + () async { + final owner = BackupProcessingOwner(); + final lease = owner.acquire() + ..beginOperation() + ..beginOperation(); + lease.release(); + expect(owner.active, same(lease)); + lease.endOperation(); + expect(owner.active, same(lease)); + lease.endOperation(); + expect(owner.active, isNull); + }, + ); +} diff --git a/test/domain/entities/backup_receipt_test.dart b/test/domain/entities/backup_receipt_test.dart new file mode 100644 index 00000000..ed2811b6 --- /dev/null +++ b/test/domain/entities/backup_receipt_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; + +void main() { + test( + 'unknown authority cannot be constructed with terminal or arbitrary failure state', + () { + for (final pending in [true, false]) { + expect( + () => BackupRestoreReceipt( + disposition: BackupCommitDisposition.undetermined, + generation: 1, + followUpPending: pending, + failure: DataOperationFailure.failed, + ), + throwsArgumentError, + ); + } + const unknown = BackupRestoreReceipt.uncertain(generation: 1); + expect(unknown.followUpPending, true); + expect(unknown.committed, null); + expect(unknown.failure, null); + expect(unknown.recoveryFollowUp, null); + }, + ); + test('recovery phase receipts always mean committed and unfinished', () { + for (final phase in RecoveryFollowUp.values) { + final receipt = BackupRestoreReceipt.recovery( + generation: 1, + phase: phase, + ); + expect(receipt.disposition, BackupCommitDisposition.committed); + expect(receipt.followUpPending, true); + expect(receipt.failure, null); + } + }); +} diff --git a/test/domain/entities/civil_date_time_contract_test.dart b/test/domain/entities/civil_date_time_contract_test.dart new file mode 100644 index 00000000..5dd0dff1 --- /dev/null +++ b/test/domain/entities/civil_date_time_contract_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; + +void main() { + // A10: civil text describes wall fields. A suffix on historical civil text + // must not turn it into an instant; the selected occurrence offset does that. + group('preserved civil values', () { + test('foreign DST gap fields survive parsing and calendar adaptation', () { + final civil = CivilDateTime.parse('2026-03-08T02:30:15.123456'); + final adapted = CivilDateTime.fromFields(civil.toUtcCarrier()); + + expect(adapted.toCivilIso8601String(), '2026-03-08T02:30:15.123456'); + expect( + adapted.atOffset(32400), + DateTime.utc(2026, 3, 7, 17, 30, 15, 123, 456), + ); + }); + + test( + 'valid legacy suffixes preserve wall fields instead of shifting them', + () { + for (final suffix in ['', 'Z', '+09:00', '-0400', '+23:59', '-2359']) { + final civil = CivilDateTime.parse( + '2026-09-25T09:00:01.000001$suffix', + ); + + expect( + civil.toCivilIso8601String(), + '2026-09-25T09:00:01.000001', + reason: 'civil suffix $suffix is not the occurrence selection', + ); + expect( + civil.atOffset(32400), + DateTime.utc(2026, 9, 25, 0, 0, 1, 0, 1), + ); + } + }, + ); + + test( + 'local DateTime input contributes fields without device UTC conversion', + () { + final input = DateTime(2026, 9, 25, 9, 15, 21, 123, 456); + final civil = CivilDateTime.fromFields(input); + + expect(civil.toCivilIso8601String(), '2026-09-25T09:15:21.123456'); + expect( + civil.atOffset(32400), + DateTime.utc(2026, 9, 25, 0, 15, 21, 123, 456), + ); + }, + ); + + test( + 'leap-day and calendar range endpoints retain subsecond precision', + () { + for (final literal in [ + '0001-01-01T00:00:00.000001', + '2000-02-29T23:59:59.999999', + '2024-02-29T12:34:56.123456', + '9999-12-31T23:59:59.999999', + ]) { + final civil = CivilDateTime.parse(literal); + expect(civil.toCivilIso8601String(), literal); + expect(civil.atOffset(0).toIso8601String(), '${literal}Z'); + } + expect( + CivilDateTime.parse('2026-09-25T09:00:00.1').toCivilIso8601String(), + '2026-09-25T09:00:00.100', + ); + }, + ); + }); + + group('invalid civil input is rejected rather than normalized', () { + test('calendar overflow does not silently move an appointment', () { + for (final literal in [ + '0000-01-01T00:00:00', + '10000-01-01T00:00:00', + '1900-02-29T12:00:00', + '2026-02-29T12:00:00', + '2026-04-31T12:00:00', + '2026-00-01T12:00:00', + '2026-13-01T12:00:00', + '2026-01-00T12:00:00', + '2026-01-01T24:00:00', + '2026-01-01T12:60:00', + '2026-01-01T12:00:60', + ]) { + expect( + () => CivilDateTime.parse(literal), + throwsFormatException, + reason: literal, + ); + } + }); + + test('invalid suffixes cannot acquire civil or instant authority', () { + for (final suffix in [ + '+24:00', + '-2400', + '+00:60', + '-0060', + '+99:99', + '+9:00', + '+00:00:01', + ]) { + expect( + () => CivilDateTime.parse('2026-09-25T09:00:00$suffix'), + throwsFormatException, + reason: suffix, + ); + } + }); + + test( + 'malformed or excessive precision is not truncated into a valid value', + () { + for (final literal in [ + '2026-09-25', + '2026-09-25 09:00:00', + '2026-09-25T09:00', + '2026-09-25T09:00:00.', + '2026-09-25T09:00:00.1234567', + '2026-09-25T09:00:00Zsuffix', + ' 2026-09-25T09:00:00', + '2026-09-25T09:00:00\n', + '2026-09-25T09:00:00\r\n', + ]) { + expect( + () => CivilDateTime.parse(literal), + throwsFormatException, + reason: literal, + ); + } + }, + ); + + test('calendar adapters reject years outside the persisted contract', () { + expect( + () => CivilDateTime.fromFields(DateTime.utc(0)), + throwsFormatException, + ); + expect( + () => CivilDateTime.fromFields(DateTime.utc(10000)), + throwsFormatException, + ); + }); + }); + + group('selected offset defines the actual occurrence', () { + test('different civil dates can describe the same appointment instant', () { + final seoul = CivilDateTime.parse('2026-09-25T09:00:00.123456'); + final newYork = CivilDateTime.parse('2026-09-24T20:00:00.123456'); + final expected = DateTime.utc(2026, 9, 25, 0, 0, 0, 123, 456); + + expect(seoul.atOffset(32400), expected); + expect(newYork.atOffset(-14400), expected); + expect(newYork.compareTo(seoul), lessThan(0)); + expect(seoul.toCivilIso8601String(), '2026-09-25T09:00:00.123456'); + expect(newYork.toCivilIso8601String(), '2026-09-24T20:00:00.123456'); + }); + + test('explicit overlap choices remain two distinct instants', () { + final repeatedCivil = CivilDateTime.parse('2026-11-01T01:30:00.000001'); + + expect( + repeatedCivil.atOffset(-14400), + DateTime.utc(2026, 11, 1, 5, 30, 0, 0, 1), + ); + expect( + repeatedCivil.atOffset(-18000), + DateTime.utc(2026, 11, 1, 6, 30, 0, 0, 1), + ); + }); + + test('second and fractional-hour offsets do not lose precision', () { + // Synthetic explicit historical offset, without asserting a tzdb version. + final historical = CivilDateTime.parse('1890-01-01T00:00:00.123456'); + expect( + historical.atOffset(561), + DateTime.utc(1889, 12, 31, 23, 50, 39, 123, 456), + ); + expect( + historical.atOffset(-561), + DateTime.utc(1890, 1, 1, 0, 9, 21, 123, 456), + ); + expect( + CivilDateTime.parse('2026-09-25T09:00:00').atOffset(20700), + DateTime.utc(2026, 9, 25, 3, 15), + ); + }); + + test( + 'portable offset endpoints are allowed without wrapping huge integers', + () { + final civil = CivilDateTime.parse('2026-09-25T00:00:00.000001'); + expect(civil.atOffset(86400), DateTime.utc(2026, 9, 24, 0, 0, 0, 0, 1)); + expect( + civil.atOffset(-86400), + DateTime.utc(2026, 9, 26, 0, 0, 0, 0, 1), + ); + for (final offset in [ + 86401, + -86401, + 9223372036854775807, + -9223372036854775808, + ]) { + expect( + () => civil.atOffset(offset), + throwsFormatException, + reason: '$offset', + ); + } + }, + ); + + test( + 'offset application rejects crossing either persisted year boundary', + () { + final first = CivilDateTime.parse('0001-01-01T00:00:00'); + final last = CivilDateTime.parse('9999-12-31T23:59:59.999999'); + + expect(first.atOffset(-1), DateTime.utc(1, 1, 1, 0, 0, 1)); + expect( + last.atOffset(1), + DateTime.utc(9999, 12, 31, 23, 59, 58, 999, 999), + ); + expect(() => first.atOffset(1), throwsFormatException); + expect(() => last.atOffset(-1), throwsFormatException); + }, + ); + }); +} diff --git a/test/domain/entities/preparation_snapshot_validation_test.dart b/test/domain/entities/preparation_snapshot_validation_test.dart index 54ef4cfe..bf648b02 100644 --- a/test/domain/entities/preparation_snapshot_validation_test.dart +++ b/test/domain/entities/preparation_snapshot_validation_test.dart @@ -23,6 +23,9 @@ ScheduleWithPreparationEntity fixture({ scheduleNote: 'private note', scheduleTime: DateTime.utc(2026, 9, 23, 10, minute), timeZoneId: zone, + // This fixture represents a known running instant. Legacy null history is + // separately covered by civil_legacy_runtime_migration_test. + occurrenceOffsetSeconds: zone == 'Asia/Seoul' ? 32400 : 0, moveTime: Duration(minutes: move), scheduleSpareTime: Duration(minutes: spare), isChanged: false, @@ -155,7 +158,7 @@ void main() { { 'type': 'schedule_alarm', 'scheduleId': 'schedule', - 'alarmLaunchPayloadVersion': '9', + 'alarmLaunchPayloadVersion': '10', 'promptVariant': 'alarm', }, ); diff --git a/test/domain/entities/preparation_timing_entity_test.dart b/test/domain/entities/preparation_timing_entity_test.dart index cf271d89..f1326d54 100644 --- a/test/domain/entities/preparation_timing_entity_test.dart +++ b/test/domain/entities/preparation_timing_entity_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; @@ -65,7 +66,8 @@ void main() { id: 'schedule-1', place: PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 3, 20, 10, 0), + scheduleTime: DateTime.utc(2026, 3, 20, 10, 0), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 20), isChanged: false, isStarted: false, @@ -75,7 +77,7 @@ void main() { ); test('calculates left time before leave with explicit now', () { - final now = DateTime(2026, 3, 20, 9, 20); + final now = DateTime.utc(2026, 3, 20, 9, 20); expect( schedule.timeRemainingBeforeLeavingAt(now), const Duration(minutes: 10), @@ -83,10 +85,10 @@ void main() { }); test('reports late status after leave time has passed', () { - final now = DateTime(2026, 3, 20, 9, 20); + final now = DateTime.utc(2026, 3, 20, 9, 20); expect(schedule.isLateAt(now), isFalse); - final lateNow = DateTime(2026, 3, 20, 9, 31); + final lateNow = DateTime.utc(2026, 3, 20, 9, 31); expect(schedule.timeRemainingBeforeLeavingAt(lateNow).isNegative, isTrue); expect(schedule.isLateAt(lateNow), isTrue); }); @@ -190,7 +192,8 @@ void main() { id: 'schedule-cache', place: PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 3, 20, 10, 0), + scheduleTime: DateTime.utc(2026, 3, 20, 10, 0), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 20), isChanged: false, isStarted: false, @@ -211,7 +214,8 @@ void main() { id: 'schedule-cache', place: PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 3, 20, 10, 0), + scheduleTime: DateTime.utc(2026, 3, 20, 10, 0), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 20), isChanged: false, isStarted: false, @@ -239,7 +243,7 @@ void main() { expect(schedule.totalDuration, const Duration(minutes: 50)); expect( schedule.preparationStartTime, - DateTime(2026, 3, 20, 9, 10).toUtc(), + DateTime.utc(2026, 3, 20, 9, 10).toUtc(), ); expect(schedule.timeRemainingBeforeLeaving.inMinutes, isA()); expect(schedule.isLate, isA()); @@ -256,7 +260,8 @@ void main() { id: 'schedule-combine', place: const PlaceEntity(id: 'p2', placeName: 'Gym'), scheduleName: 'Workout', - scheduleTime: DateTime(2026, 3, 21, 8), + scheduleTime: DateTime.utc(2026, 3, 21, 8), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 15), isChanged: true, isStarted: true, @@ -270,6 +275,10 @@ void main() { ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( base, preparation, + timeResolution: ScheduleTimeResolver.resolve( + base, + nowUtc: DateTime.now().toUtc(), + ), ); expect(combined.id, base.id); @@ -296,7 +305,8 @@ void main() { id: 'schedule-log', place: const PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 5, 1, 9), + scheduleTime: DateTime.utc(2026, 5, 1, 9), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 10), isChanged: false, isStarted: false, @@ -328,7 +338,8 @@ void main() { id: 'schedule-2', place: PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 3, 20, 10, 0), + scheduleTime: DateTime.utc(2026, 3, 20, 10, 0), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 20), isChanged: false, isStarted: false, @@ -338,7 +349,7 @@ void main() { ); final state = ScheduleState.upcoming(schedule); - final now = DateTime(2026, 3, 20, 9, 15); + final now = DateTime.utc(2026, 3, 20, 9, 15); expect( state.durationUntilPreparationStartAt(now), const Duration(minutes: 5), diff --git a/test/domain/entities/resolved_schedule_runtime_test.dart b/test/domain/entities/resolved_schedule_runtime_test.dart new file mode 100644 index 00000000..cee7f999 --- /dev/null +++ b/test/domain/entities/resolved_schedule_runtime_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; + +void main() { + final raw = ScheduleEntity( + id: 'raw', + place: const PlaceEntity(id: 'place', placeName: 'Place'), + scheduleName: 'UTC', + scheduleTime: DateTime.utc(2010, 1, 1, 12), + timeZoneId: 'UTC', + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + const preparation = PreparationWithTimeEntity(preparationStepList: []); + + test( + 'a fixed pass remains deterministic even when actual wall clock is after its occurrence', + () { + final runtime = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + raw, + preparation, + timeResolution: ScheduleTimeResolver.resolve( + raw, + nowUtc: DateTime.utc(2009), + ), + ); + expect(runtime.occurrenceOffsetSeconds, isNull); + expect(runtime.occurrenceInstantUtc, DateTime.utc(2010, 1, 1, 12)); + expect(runtime.preparationStartTime, DateTime.utc(2010, 1, 1, 12)); + final identity = runtime.cacheFingerprint; + expect(runtime.cacheFingerprint, identity); + final confirmed = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + raw.copyWith( + occurrenceOffsetSeconds: 0, + isStarted: true, + preparationFrozen: true, + startedAt: DateTime.utc(2010, 1, 1, 11), + ), + preparation, + timeResolution: ScheduleTimeResolver.resolve( + raw.copyWith( + occurrenceOffsetSeconds: 0, + isStarted: true, + preparationFrozen: true, + startedAt: DateTime.utc(2010, 1, 1, 11), + ), + nowUtc: DateTime.utc(2011), + ), + ); + expect(confirmed.cacheFingerprint, identity); + expect(raw.occurrenceOffsetSeconds, isNull); + }, + ); + + test( + 'another pass after the deadline is unresolved and cannot reuse runtime identity as authority', + () { + final before = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + raw, + preparation, + timeResolution: ScheduleTimeResolver.resolve( + raw, + nowUtc: DateTime.utc(2009), + ), + ); + final after = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + raw, + preparation, + timeResolution: ScheduleTimeResolver.resolve( + raw, + nowUtc: DateTime.utc(2011), + ), + ); + expect( + after.timeResolution!.status, + ScheduleTimeResolutionStatus.historicalUncertain, + ); + expect(after, isNot(before)); + expect( + () => after.cacheFingerprint, + throwsA(isA()), + ); + expect(before.occurrenceInstantUtc, DateTime.utc(2010, 1, 1, 12)); + }, + ); + + test( + 'same raw values with changed occurrence produce distinct runtime state and fingerprint', + () { + ScheduleWithPreparationEntity interpreted(int offset) => + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + raw, + preparation, + timeResolution: ScheduleTimeResolver.resolve( + raw, + nowUtc: DateTime.utc(2009), + lookup: (_, _) => [ + CivilTimeOccurrence( + offsetSeconds: offset, + instantUtc: DateTime.utc( + 2010, + 1, + 1, + 12, + ).subtract(Duration(seconds: offset)), + ), + ], + ), + ); + final first = interpreted(0); + final same = interpreted(0); + final changed = interpreted(3600); + expect(first, same); + expect(changed, isNot(first)); + expect(changed.cacheFingerprint, isNot(first.cacheFingerprint)); + expect(first.occurrenceOffsetSeconds, isNull); + expect(changed.occurrenceOffsetSeconds, isNull); + }, + ); +} diff --git a/test/domain/use-cases/adjacent_schedule_instant_integration_test.dart b/test/domain/use-cases/adjacent_schedule_instant_integration_test.dart new file mode 100644 index 00000000..79e9b79c --- /dev/null +++ b/test/domain/use-cases/adjacent_schedule_instant_integration_test.dart @@ -0,0 +1,272 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; +import 'package:on_time_front/data/repositories/preparation_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/user_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; + +// Actual SQLite, public schedule/preparation writers and range streams. This is +// not an OS notification or native-device test. +void main() { + late AppDatabase database; + late ScheduleRepositoryImpl schedules; + late UserRepositoryImpl users; + late PreparationRepositoryImpl preparations; + late GetAdjacentSchedulesWithPreparationUseCase adjacent; + + setUp(() async { + database = AppDatabase.forTesting(NativeDatabase.memory()); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 60), + note: '', + isOnboardingCompleted: true, + ), + ); + schedules = ScheduleRepositoryImpl( + database: database, + timedPreparationRepository: _UnusedRuntime(), + ); + users = UserRepositoryImpl(database); + preparations = PreparationRepositoryImpl( + preparationLocalDataSource: PreparationLocalDataSourceImpl( + appDatabase: database, + ), + userRepository: users, + database: database, + ); + adjacent = GetAdjacentSchedulesWithPreparationUseCase( + GetSchedulesByDateUseCase(schedules), + GetPreparationByScheduleIdUseCase(preparations), + ); + }); + + tearDown(() async { + await preparations.dispose(); + await users.dispose(); + await schedules.dispose(); + await database.close(); + }); + + Future create( + String id, + DateTime civil, + String zone, + int offset, + ) async { + await schedules.createSchedule( + ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Office'), + scheduleName: id, + scheduleTime: civil, + timeZoneId: zone, + occurrenceOffsetSeconds: offset, + moveTime: const Duration(minutes: 2), + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: '', + ), + ); + await preparations.createCustomPreparation( + PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-$id', + preparationName: 'Prepare', + preparationTime: const Duration(minutes: 5), + ), + ], + ), + id, + ); + } + + Future settle(int count) async { + await schedules.scheduleStream.firstWhere((value) => value.length == count); + await preparations.preparationStream.firstWhere( + (value) => value.length == count, + ); + } + + Future>> stored() async => [ + for (final row + in await database + .customSelect('SELECT * FROM schedules ORDER BY id') + .get()) + row.data, + for (final row + in await database.customSelect('SELECT * FROM users ORDER BY id').get()) + row.data, + ]; + + test( + 'a previous civil day at the same instant is returned as the next conflict candidate', + () async { + // New York is still January 1, while the selected occurrence is January 2 UTC. + await create( + 'same-instant', + DateTime.utc(2030, 1, 1, 19, 15), + 'America/New_York', + -18000, + ); + await create( + 'previous', + DateTime.utc(2030, 1, 2, 9, 14), + 'Asia/Seoul', + 32400, + ); + await create('later', DateTime.utc(2030, 1, 2, 1), 'UTC', 0); + await settle(3); + final before = await stored(); + final result = await adjacent( + selectedDateTime: DateTime.utc(2030, 1, 2, 0, 15), + startDate: DateTime.utc(2030, 1, 2), + endDate: DateTime.utc(2030, 1, 3), + ); + expect(result.nextSchedule?.id, 'same-instant'); + expect( + result.nextSchedule?.occurrenceInstantUtc, + DateTime.utc(2030, 1, 2, 0, 15), + ); + expect( + result.nextSchedule?.preparationStartTime, + DateTime.utc(2030, 1, 2, 0, 8), + ); + expect(result.previousSchedule?.id, 'previous'); + expect( + result.previousSchedule?.occurrenceInstantUtc, + DateTime.utc(2030, 1, 2, 0, 14), + ); + expect( + result.nextSchedule?.scheduleTime, + DateTime.utc(2030, 1, 1, 19, 15), + ); + expect(await stored(), before); + }, + ); + + test( + 'UTC plus14 selected civil includes the same instant two civil dates earlier at minus11', + () async { + await create( + 'self-east', + DateTime.utc(2030, 1, 2, 0, 30), + 'Pacific/Kiritimati', + 50400, + ); + await create( + 'same-west', + DateTime.utc(2029, 12, 31, 23, 30), + 'Pacific/Pago_Pago', + -39600, + ); + await create( + 'later-east', + DateTime.utc(2030, 1, 2, 0, 31), + 'Pacific/Kiritimati', + 50400, + ); + await settle(3); + final before = await stored(); + final raw = await schedules.getScheduleById('same-west'); + final time = ScheduleTimeResolver.resolve( + raw, + nowUtc: DateTime.utc(2026), + ); + expect( + time.status, + ScheduleTimeResolutionStatus.resolved, + reason: 'Fixture zone must exist and match its explicit offset', + ); + final range = await schedules + .watchSchedulesByDate( + DateTime.utc(2029, 12, 31), + DateTime.utc(2030, 1, 5), + ) + .first; + expect( + range.map((row) => row.id), + contains('same-west'), + reason: 'Actual expanded civil query must include fixture', + ); + final result = await adjacent( + selectedDateTime: DateTime.utc(2030, 1, 1, 10, 30), + currentScheduleId: 'self-east', + startDate: DateTime.utc(2030, 1, 2), + endDate: DateTime.utc(2030, 1, 3), + ); + expect(result.nextSchedule?.id, 'same-west'); + expect( + result.nextSchedule?.occurrenceInstantUtc, + DateTime.utc(2030, 1, 1, 10, 30), + ); + expect( + result.nextSchedule?.scheduleTime, + DateTime.utc(2029, 12, 31, 23, 30), + ); + expect(await stored(), before); + }, + ); + + test( + 'opposite civil-date ordering and self exclusion use actual occurrence instants', + () async { + await create('self', DateTime.utc(2030, 1, 2, 0, 15), 'UTC', 0); + await create( + 'earlier', + DateTime.utc(2030, 1, 2, 9, 14), + 'Asia/Seoul', + 32400, + ); + await create( + 'later', + DateTime.utc(2030, 1, 1, 19, 16), + 'America/New_York', + -18000, + ); + await settle(3); + final before = await stored(); + final result = await adjacent( + selectedDateTime: DateTime.utc(2030, 1, 2, 0, 15), + currentScheduleId: 'self', + startDate: DateTime.utc(2030, 1, 2), + endDate: DateTime.utc(2030, 1, 3), + ); + expect(result.previousSchedule?.id, 'earlier'); + expect(result.nextSchedule?.id, 'later'); + expect( + result.previousSchedule!.scheduleTime.isAfter( + result.nextSchedule!.scheduleTime, + ), + isTrue, + ); + expect( + result.previousSchedule!.occurrenceInstantUtc.isBefore( + result.nextSchedule!.occurrenceInstantUtc, + ), + isTrue, + ); + expect(await stored(), before); + }, + ); +} + +class _UnusedRuntime implements TimedPreparationRepository { + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('Adjacent lookup must not read transient runtime.'); +} diff --git a/test/domain/use-cases/detailed_notification_content_guard_test.dart b/test/domain/use-cases/detailed_notification_content_guard_test.dart new file mode 100644 index 00000000..c5c5d9c1 --- /dev/null +++ b/test/domain/use-cases/detailed_notification_content_guard_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'alarm_reconciliation_concurrency_test.dart' as fixtures; + +void main() { + test('a completed OFF never reauthorizes an old ON content permit', () { + final gate = LocalDataOperationGate(); + final owner = AlarmOperationCoordinator(gate); + addTearDown(owner.dispose); + addTearDown(gate.dispose); + final oldOn = owner.captureContentPermit(); + expect(owner.allowsDetailed(oldOn), isTrue); + final off = owner.acceptContentIntent(false, expectedGeneration: 0); + expect(owner.allowsDetailed(oldOn), isFalse); + owner.confirmContentIntent(off, committedEnabled: false); + expect(owner.allowsDetailed(oldOn), isFalse); + final on = owner.acceptContentIntent(true, expectedGeneration: 0); + owner.confirmContentIntent(on, committedEnabled: true); + expect(owner.allowsDetailed(on), isTrue); + expect(owner.allowsDetailed(oldOn), isFalse); + expect(owner.allowsDetailed(off), isFalse); + }); + + test( + 'failed OFF ON readback retains hold; new generation drops unsaved intent', + () async { + final gate = LocalDataOperationGate(); + final owner = AlarmOperationCoordinator(gate); + addTearDown(owner.dispose); + addTearDown(gate.dispose); + final off = owner.acceptContentIntent(false, expectedGeneration: 0); + owner.confirmContentIntent(off, committedEnabled: true); + expect(owner.allowsDetailed(owner.captureContentPermit()), isFalse); + await gate.run(() async {}, replacesData: true); + expect(owner.allowsDetailed(owner.captureContentPermit()), isTrue); + expect(owner.allowsDetailed(off), isFalse); + }, + ); + + test( + 'OFF accepted after settings snapshot prevents detailed provider send', + () async { + final rig = fixtures.Rig(); + addTearDown(rig.dispose); + rig.repository.settings = const AlarmSettings( + alarmsEnabled: true, + detailedNotificationContent: true, + ); + rig.repository.schedules = [rig.schedule('old-on')]; + rig.repository.block(1); + final pass = rig.reconcile(); + await rig.repository.snapshots[1]!.future; + final off = rig.operations.acceptContentIntent( + false, + expectedGeneration: 0, + ); + rig.repository.settings = const AlarmSettings(alarmsEnabled: true); + rig.operations.confirmContentIntent(off, committedEnabled: false); + rig.repository.releases[1]!.complete(); + final result = await pass; + expect(rig.fallback.scheduledFallback, isEmpty); + expect( + result.failures.map((failure) => failure.reason), + contains(AlarmFailureReason.contentDeferred), + ); + await rig.reconcile(); + expect( + rig.fallback.scheduledFallback.single.deliveryContent.detailed, + isFalse, + ); + }, + ); + + test( + 'privacy hold preserves valid private registration against detailed replacement', + () async { + final rig = fixtures.Rig(); + addTearDown(rig.dispose); + rig.repository.schedules = [rig.schedule('private')]; + await rig.reconcile(); + final count = rig.fallback.scheduledFallback.length; + rig.repository.settings = const AlarmSettings( + alarmsEnabled: true, + detailedNotificationContent: true, + ); + rig.operations.acceptContentIntent(false, expectedGeneration: 0); + final result = await rig.reconcile(); + expect(rig.fallback.canceledFallback, isEmpty); + expect(rig.fallback.scheduledFallback.length, count); + expect(rig.registry.records.single.deliveryContent.detailed, isFalse); + expect(result.status, AlarmReconciliationStatus.partial); + expect(result.armedScheduleIds, isEmpty); + }, + ); + + test( + 'hold does not prevent current private scheduling or unrelated cleanup', + () async { + final rig = fixtures.Rig(); + addTearDown(rig.dispose); + rig.repository.schedules = [rig.schedule('old')]; + await rig.reconcile(); + rig.operations.acceptContentIntent(false, expectedGeneration: 0); + rig.repository.schedules = [rig.schedule('private-new')]; + await rig.reconcile(); + expect( + rig.fallback.canceledFallback.map((record) => record.scheduleId), + contains('old'), + ); + expect(rig.registry.records.single.scheduleId, 'private-new'); + expect(rig.registry.records.single.deliveryContent.detailed, isFalse); + }, + ); +} diff --git a/test/domain/use-cases/detailed_notification_delivery_acceptance_test.dart b/test/domain/use-cases/detailed_notification_delivery_acceptance_test.dart new file mode 100644 index 00000000..c92c92d9 --- /dev/null +++ b/test/domain/use-cases/detailed_notification_delivery_acceptance_test.dart @@ -0,0 +1,527 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/core/services/alarm_journal_store.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; +import 'package:on_time_front/data/repositories/alarm_repository_impl.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/presentation/my_page/cubit/detailed_notification_settings_cubit.dart'; + +import '../../helpers/schedule_deletion_fixture.dart'; +import 'reconcile_alarms_use_case_test.dart' + show + FakeAlarmRegistryRepository, + FakeAlarmSchedulerService, + FakeFallbackAlarmNotificationService; + +class _UnusedPreparation extends Fake implements PreparationRepository {} + +class _DatabaseAlarms extends AlarmRepositoryImpl { + _DatabaseAlarms(ScheduleDeletionFixture f) + : super( + database: f.db, + scheduleRepository: f.schedules, + preparationRepository: _UnusedPreparation(), + ); + bool holdSettings = false; + final readEntered = Completer(), readRelease = Completer(); + @override + Future getAlarmSettings() async { + final actual = await super.getAlarmSettings(); + if (holdSettings) { + holdSettings = false; + readEntered.complete(); + await readRelease.future; + } + return actual; + } +} + +class _Native extends FakeAlarmSchedulerService { + bool holdNext = false, failAfterHold = false; + final entered = Completer(), release = Completer(); + @override + Future scheduleNativeAlarm(ScheduledAlarmRecord record) async { + if (holdNext) { + holdNext = false; + entered.complete(); + await release.future; + if (failAfterHold) { + throw const AlarmSchedulingException( + reason: AlarmFailureReason.platformError, + message: 'synthetic native failure', + ); + } + } + return super.scheduleNativeAlarm(record); + } +} + +class _JournalBarrier { + _JournalBarrier(this.directory); + final Directory directory; + bool armed = false; + final entered = Completer(), release = Completer(); + Future checkpoint(String stage) async { + if (!armed || stage != 'readBack') { + return; + } + final raw = await File( + '${directory.path}/ownership-v1.json', + ).readAsString(); + if (AlarmJournalSnapshot.decode(raw).ownership.isEmpty) { + return; + } + armed = false; + entered.complete(); + await release.future; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late ScheduleDeletionFixture f; + late DetailedNotificationPreferenceService preferences; + late AlarmOperationCoordinator owner; + late Directory journalDirectory; + late _JournalBarrier journalBarrier; + late _DatabaseAlarms repository; + late FakeAlarmRegistryRepository registry; + late _Native native; + late FakeFallbackAlarmNotificationService fallback; + late ReconcileAlarmsUseCase reconcile; + late DetailedNotificationSettingsCubit controller; + + setUp(() async { + f = ScheduleDeletionFixture(); + await f.initialize(); + await f.create('visit'); + preferences = DetailedNotificationPreferenceService(f.db, gate: f.gate); + journalDirectory = await Directory.systemTemp.createTemp( + 'ontime-u03-journal-', + ); + journalBarrier = _JournalBarrier(journalDirectory); + owner = AlarmOperationCoordinator( + f.gate, + journal: AlarmOwnershipJournal( + FileAlarmJournalStore( + () async => journalDirectory, + checkpoint: journalBarrier.checkpoint, + ), + ), + ); + + repository = _DatabaseAlarms(f); + registry = FakeAlarmRegistryRepository(); + native = _Native() + ..capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.iosAlarmKit, + ); + fallback = FakeFallbackAlarmNotificationService() + ..permission = AlarmPermissionState.granted; + reconcile = ReconcileAlarmsUseCase.test( + repository, + registry, + native, + fallback, + operations: owner, + nowProvider: () => DateTime.utc(2030, 1, 2, 9), + languageCodeProvider: () => 'en', + timeZoneProvider: () async => 'UTC', + ); + controller = DetailedNotificationSettingsCubit.test( + preferences, + reconcile, + operations: owner, + ); + }); + tearDown(() async { + if (!repository.readRelease.isCompleted) repository.readRelease.complete(); + if (!native.release.isCompleted) native.release.complete(); + if (!journalBarrier.release.isCompleted) { + journalBarrier.release.complete(); + } + await controller.close(); + await reconcile(); + owner.dispose(); + await f.close(); + await journalDirectory.delete(recursive: true); + }); + + Future stateWhere( + bool Function(DetailedNotificationSettingsState) match, + ) async { + if (match(controller.state)) return; + await controller.stream + .firstWhere(match) + .timeout(const Duration(seconds: 10)); + } + + Future durableOn() async { + final permit = owner.acceptContentIntent( + true, + expectedGeneration: f.gate.generation, + ); + final saved = await preferences.write( + true, + expectedGeneration: f.gate.generation, + ); + owner.confirmContentIntent(permit, committedEnabled: saved.detailedEnabled); + } + + test( + 'OFF accepted after old ON snapshot preserves a valid private registration', + () async { + await controller.refresh(); + expect(registry.records.single.deliveryContent.detailed, isFalse); + final original = registry.records.single; + await durableOn(); + repository.holdSettings = true; + final running = reconcile(); + try { + await repository.readEntered.future.timeout( + const Duration(seconds: 10), + ); + final oldPermit = owner.captureContentPermit(); + final off = owner.acceptContentIntent(false, expectedGeneration: 0); + await preferences.write(false, expectedGeneration: 0); + owner.confirmContentIntent(off, committedEnabled: false); + expect(owner.allowsDetailed(oldPermit), isFalse); + repository.readRelease.complete(); + final result = await running; + expect( + result.failures.map((e) => e.reason), + contains(AlarmFailureReason.contentDeferred), + ); + expect(native.canceledNative, isEmpty); + expect(native.scheduledNative, hasLength(1)); + expect(registry.records.single, original); + expect(fallback.scheduledFallback, isEmpty); + } finally { + if (!repository.readRelease.isCompleted) { + repository.readRelease.complete(); + } + await running; + } + }, + ); + + test( + 'failed OFF and ON read-back block external detailed sends until a new ON intent', + () async { + await controller.refresh(); + await durableOn(); + owner.acceptContentIntent(false, expectedGeneration: 0); + await controller.refresh(); + // The durable preference is ON, while the still-valid OS registration + // remains private until a reconciliation actually replaces it. + final sentBefore = native.scheduledNative.length; + await f.db.customStatement( + "CREATE TRIGGER reject_private BEFORE UPDATE OF " + "detailed_notification_content ON users WHEN NEW.detailed_notification_content=0 " + "BEGIN SELECT RAISE(ABORT,'synthetic private write failure'); END", + ); + controller.request(false); + await stateWhere((s) => s.save == DetailedPreferenceSave.failed); + expect(controller.state.confirmedEnabled, isTrue); + expect(controller.state.requestedEnabled, isFalse); + expect( + (await f.db.select(f.db.users).getSingle()).detailedNotificationContent, + isTrue, + ); + final held = owner.captureContentPermit(); + expect(owner.allowsDetailed(held), isFalse); + // Force a real replacement need while keeping the same schedule identity. + await f.db.customStatement( + "UPDATE schedules SET schedule_name='Changed while held'", + ); + await reconcile(); + expect(native.scheduledNative.length, sentBefore); + expect(native.canceledNative, isEmpty); + expect(registry.records.single.deliveryContent.detailed, isFalse); + expect(fallback.scheduledFallback, isEmpty); + await f.db.customStatement('DROP TRIGGER reject_private'); + controller.request(true); + await stateWhere( + (s) => + s.save == DetailedPreferenceSave.idle && + s.delivery == DetailedPreferenceDelivery.applied, + ); + expect(native.scheduledNative.last.deliveryContent.detailed, isTrue); + expect( + native.scheduledNative.last.deliveryContent.title, + contains('Changed while held'), + ); + expect(owner.allowsDetailed(held), isFalse); + }, + ); + + test( + 'OFF DB commit does not wait for an already submitted native Future', + () async { + await controller.refresh(); + native.holdNext = true; + controller.request(true); + try { + await native.entered.future.timeout(const Duration(seconds: 10)); + controller.request(false); + await stateWhere( + (s) => + s.confirmedEnabled == false && + s.save == DetailedPreferenceSave.idle, + ); + expect( + (await f.db.select(f.db.users).getSingle()) + .detailedNotificationContent, + isFalse, + ); + expect(native.release.isCompleted, isFalse); + native.release.complete(); + await stateWhere( + (s) => s.delivery == DetailedPreferenceDelivery.applied, + ); + expect(registry.records.single.deliveryContent.detailed, isFalse); + expect(native.scheduledNative.last.deliveryContent.detailed, isFalse); + } finally { + if (!native.release.isCompleted) native.release.complete(); + } + }, + ); + + test( + 'OFF during failing native send forbids its detailed fallback', + () async { + await durableOn(); + native.holdNext = true; + native.failAfterHold = true; + final running = reconcile(); + try { + await native.entered.future.timeout(const Duration(seconds: 10)); + final off = owner.acceptContentIntent(false, expectedGeneration: 0); + await preferences.write(false, expectedGeneration: 0); + owner.confirmContentIntent(off, committedEnabled: false); + native.release.complete(); + final result = await running; + expect(fallback.scheduledFallback, isEmpty); + expect( + result.failures.map((e) => e.reason), + contains(AlarmFailureReason.contentDeferred), + ); + } finally { + if (!native.release.isCompleted) native.release.complete(); + await running; + } + }, + ); + + test( + 'saved OFF reports cancellation uncertainty without pretending OS became private', + () async { + await durableOn(); + await controller.refresh(); + native.throwOnCancelIds.add('visit'); + controller.request(false); + await stateWhere( + (s) => s.delivery == DetailedPreferenceDelivery.cancellationUnconfirmed, + ); + expect(controller.state.confirmedEnabled, isFalse); + expect( + (await f.db.select(f.db.users).getSingle()).detailedNotificationContent, + isFalse, + ); + expect(native.scheduledNative.last.deliveryContent.detailed, isTrue); + expect(native.pendingNative, contains('visit')); + native.throwOnCancelIds.clear(); + await controller.retry(); + expect(controller.state.delivery, DetailedPreferenceDelivery.applied); + expect(registry.records.single.deliveryContent.detailed, isFalse); + }, + ); + + test( + 'replacement scheduling gap is a failure even after preference saved', + () async { + await controller.refresh(); + native.throwOnScheduleIds.add('visit'); + fallback.throwOnScheduleIds.add('visit'); + controller.request(true); + await stateWhere( + (s) => s.delivery == DetailedPreferenceDelivery.schedulingFailed, + ); + expect(controller.state.confirmedEnabled, isTrue); + expect(native.pendingNative, isEmpty); + expect(fallback.pendingFallback, isEmpty); + expect( + (await f.db.select(f.db.users).getSingle()).detailedNotificationContent, + isTrue, + ); + }, + ); + + for (final useNative in [true, false]) { + test( + 'ownership read-back then OFF prevents ${useNative ? 'native' : 'fallback'} detailed send', + () async { + if (!useNative) { + native.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + ); + } + await durableOn(); + journalBarrier.armed = true; + final running = reconcile(); + try { + await Future.any([ + journalBarrier.entered.future, + running.then( + (_) => fail('provider pass ended before ownership barrier'), + ), + ]).timeout(const Duration(seconds: 10)); + final raw = await File( + '${journalDirectory.path}/ownership-v1.json', + ).readAsString(); + final ownership = AlarmJournalSnapshot.decode(raw); + expect(ownership.ownership, hasLength(1)); + expect(raw, isNot(contains('Name visit'))); + expect(raw, isNot(contains('Note visit'))); + expect(raw, isNot(contains('visit first'))); + final off = owner.acceptContentIntent(false, expectedGeneration: 0); + await preferences.write(false, expectedGeneration: 0); + owner.confirmContentIntent(off, committedEnabled: false); + journalBarrier.release.complete(); + final result = await running; + expect( + result.failures.map((e) => e.reason), + contains(AlarmFailureReason.contentDeferred), + ); + expect(native.scheduledNative, isEmpty); + expect(fallback.scheduledFallback, isEmpty); + await reconcile(); + expect(registry.records.single.deliveryContent.detailed, isFalse); + expect( + (useNative ? native.scheduledNative : fallback.scheduledFallback) + .single + .deliveryContent + .detailed, + isFalse, + ); + } finally { + if (!journalBarrier.release.isCompleted) { + journalBarrier.release.complete(); + } + await running; + } + }, + ); + } + + test( + 'OFF during cancellation ownership read-back preserves the valid private OS alarm', + () async { + await controller.refresh(); + final privateRecord = registry.records.single; + await durableOn(); + journalBarrier.armed = true; + final running = reconcile(); + try { + await Future.any([ + journalBarrier.entered.future, + running.then( + (_) => fail('pass ended before cancellation ownership barrier'), + ), + ]).timeout(const Duration(seconds: 10)); + final off = owner.acceptContentIntent(false, expectedGeneration: 0); + await preferences.write(false, expectedGeneration: 0); + owner.confirmContentIntent(off, committedEnabled: false); + journalBarrier.release.complete(); + final result = await running; + expect( + result.failures.map((e) => e.reason), + contains(AlarmFailureReason.contentDeferred), + ); + expect(native.canceledNative, isEmpty); + expect(native.scheduledNative, hasLength(1)); + expect(native.pendingNative, contains('visit')); + expect(registry.records.single, privateRecord); + expect(fallback.scheduledFallback, isEmpty); + } finally { + if (!journalBarrier.release.isCompleted) { + journalBarrier.release.complete(); + } + await running; + } + }, + ); + + test( + 'fresh runtime discards failed OFF intent and rereads durable ON with the existing file journal', + () async { + await controller.refresh(); + await durableOn(); + owner.acceptContentIntent(false, expectedGeneration: 0); + await controller.refresh(); + expect(registry.records.single.deliveryContent.detailed, isFalse); + await f.db.customStatement( + "CREATE TRIGGER reject_off_restart BEFORE UPDATE OF " + "detailed_notification_content ON users WHEN NEW.detailed_notification_content=0 " + "BEGIN SELECT RAISE(ABORT,'synthetic failed OFF'); END", + ); + controller.request(false); + await stateWhere((s) => s.save == DetailedPreferenceSave.failed); + expect(controller.state.confirmedEnabled, isTrue); + expect(controller.state.requestedEnabled, isFalse); + expect(owner.allowsDetailed(owner.captureContentPermit()), isFalse); + final before = await f.db.select(f.db.users).getSingle(); + final raw = await File( + '${journalDirectory.path}/ownership-v1.json', + ).readAsString(); + expect(AlarmJournalSnapshot.decode(raw).ownership, hasLength(1)); + expect(raw, isNot(contains('requestedEnabled'))); + expect(raw, isNot(contains('Name visit'))); + await controller.close(); + owner.dispose(); + registry = FakeAlarmRegistryRepository(); + // Runtime recreation over the same actual DB and cold file read. This is + // deliberately not called an OS process-kill or a database-reopen test. + owner = AlarmOperationCoordinator( + f.gate, + journal: AlarmOwnershipJournal( + FileAlarmJournalStore(() async => journalDirectory), + ), + ); + reconcile = ReconcileAlarmsUseCase.test( + repository, + registry, + native, + fallback, + operations: owner, + nowProvider: () => DateTime.utc(2030, 1, 2, 9), + languageCodeProvider: () => 'en', + timeZoneProvider: () async => 'UTC', + ); + controller = DetailedNotificationSettingsCubit.test( + preferences, + reconcile, + operations: owner, + ); + expect(controller.state.requestedEnabled, isNull); + await controller.refresh(); + expect(controller.state.confirmedEnabled, isTrue); + expect(controller.state.requestedEnabled, isNull); + expect(controller.state.delivery, DetailedPreferenceDelivery.applied); + expect(registry.records.single.deliveryContent.detailed, isTrue); + expect(native.scheduledNative.last.deliveryContent.detailed, isTrue); + final after = await f.db.select(f.db.users).getSingle(); + expect(after.dataRevision, before.dataRevision); + expect(after.storeIncarnation, before.storeIncarnation); + expect(after.detailedNotificationContent, isTrue); + }, + ); +} diff --git a/test/domain/use-cases/get_nearest_upcoming_schedule_use_case_test.dart b/test/domain/use-cases/get_nearest_upcoming_schedule_use_case_test.dart index 5e3f1a29..0d6d7a0d 100644 --- a/test/domain/use-cases/get_nearest_upcoming_schedule_use_case_test.dart +++ b/test/domain/use-cases/get_nearest_upcoming_schedule_use_case_test.dart @@ -1,60 +1,381 @@ +import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_schedules_for_week_use_case.dart'; - -class StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { - StubGetSchedulesByDateUseCase(this.handler); - final Stream> Function(DateTime, DateTime) handler; +class QueryRepositoryFixture implements NearestScheduleQueryRepository { + final source = StreamController.broadcast(); + late Future Function(NearestQueryKey) + openHandler; + final keys = []; + @override + Stream get changes => source.stream; @override - Stream> call(DateTime startDate, DateTime endDate) { - return handler(startDate, endDate); + Future open(NearestQueryKey key) { + keys.add(key); + return openHandler(key); } -} -class StubLoadPreparationByScheduleIdUseCase - implements LoadPreparationByScheduleIdUseCase { @override - Future call(String scheduleId) async {} + Future readActive() async => null; } -class StubGetPreparationByScheduleIdUseCase - implements GetPreparationByScheduleIdUseCase { +class QuerySessionFixture implements NearestScheduleQuerySession { + QuerySessionFixture(this.handler); + final Future Function(int) handler; + bool cancelled = false; + int calls = 0; @override - Future call(String scheduleId) async { - return const PreparationEntity(preparationStepList: []); + Future advance({required int candidateBudget}) { + calls++; + return handler(candidateBudget); } -} - -class StubLoadSchedulesForWeekUseCase implements LoadSchedulesForWeekUseCase { - StubLoadSchedulesForWeekUseCase(this.handler); - final Future Function(DateTime date) handler; @override - Future call(DateTime date) { - return handler(date); + void cancel() { + cancelled = true; } } +NearestQueryKey queryKey() => NearestQueryKey( + generation: LocalDataOperationGate.shared.generation, + epoch: 1, + revision: 0, +); +NearestQueryEmpty emptyQuery(NearestQueryKey key) => NearestQueryEmpty( + authority: NearestQueryAuthority( + key: key, + storeIncarnation: 'store', + dataRevision: 0, + ruleDataIdentity: 'rules', + evaluatedAtUtc: DateTime.utc(2030), + verifiedAtUtc: DateTime.utc(2030), + ), +); + void main() { - test('swallows background week load failures before streaming cache', - () async { - final useCase = GetNearestUpcomingScheduleUseCase( - StubGetSchedulesByDateUseCase( - (_, __) => Stream.value(const []), - ), - StubLoadPreparationByScheduleIdUseCase(), - StubGetPreparationByScheduleIdUseCase(), - StubLoadSchedulesForWeekUseCase( - (_) async => throw Exception('unauthorized'), - ), - ); + test( + 'resuming a caught open error opens a current zero-budget retry', + () async { + final repository = QueryRepositoryFixture(); + repository.openHandler = (key) async { + if (repository.keys.length == 1) throw StateError('open failed'); + return QuerySessionFixture((_) async => emptyQuery(key)); + }; + final failed = Completer(); + final recovered = Completer(); + final subscription = + GetNearestUpcomingScheduleUseCase(repository)(key: queryKey()).listen( + (query) { + if (query is NearestQueryError && !failed.isCompleted) { + failed.complete(); + } + if (query is NearestQueryEmpty && !recovered.isCompleted) { + recovered.complete(); + } + }, + ); + try { + await failed.future; + subscription.pause(); + await Future.delayed(Duration.zero); + subscription.resume(); + await recovered.future.timeout(const Duration(seconds: 2)); + expect(repository.keys, hasLength(2)); + } finally { + await subscription.cancel(); + await repository.source.close(); + } + }, + ); + + test( + 'resume after a watch error retires a pending cursor before fresh read', + () async { + final repository = QueryRepositoryFixture(); + final entered = Completer(); + final pending = Completer(); + repository.openHandler = (key) async => QuerySessionFixture((_) { + if (repository.keys.length == 1) { + entered.complete(); + return pending.future; + } + return Future.value(emptyQuery(key)); + }); + final failed = Completer(); + final recovered = Completer(); + final seen = []; + final subscription = + GetNearestUpcomingScheduleUseCase(repository)(key: queryKey()).listen( + (query) { + seen.add(query); + if (query is NearestQueryError && !failed.isCompleted) { + failed.complete(); + } + if (query is NearestQueryEmpty && !recovered.isCompleted) { + recovered.complete(); + } + }, + ); + try { + await entered.future; + repository.source.addError(StateError('watch failed')); + await failed.future; + subscription.pause(); + await Future.delayed(Duration.zero); + subscription.resume(); + pending.complete(emptyQuery(repository.keys.first)); + await recovered.future.timeout(const Duration(seconds: 2)); + expect(repository.keys, hasLength(2)); + expect( + seen.whereType().single.key, + repository.keys.last, + ); + } finally { + await subscription.cancel(); + await repository.source.close(); + } + }, + ); - await expectLater(useCase(), emitsInOrder([null, emitsDone])); + test( + 'resuming a completed search revalidates its existing cursor and key', + () async { + final repository = QueryRepositoryFixture(); + late QuerySessionFixture session; + repository.openHandler = (key) async => + session = QuerySessionFixture((_) async => emptyQuery(key)); + final seen = []; + final first = Completer(); + final revalidated = Completer(); + final subscription = + GetNearestUpcomingScheduleUseCase(repository)(key: queryKey()).listen( + (query) { + seen.add(query); + if (query is NearestQueryEmpty) { + if (!first.isCompleted) { + first.complete(); + } else if (!revalidated.isCompleted) { + revalidated.complete(); + } + } + }, + ); + try { + await first.future; + subscription.pause(); + await Future.delayed(Duration.zero); + expect(session.calls, 1); + subscription.resume(); + await revalidated.future.timeout(const Duration(seconds: 2)); + expect(session.calls, 2); + expect(repository.keys, hasLength(1)); + expect(session.cancelled, isFalse); + expect(seen.whereType().map((query) => query.key), [ + repository.keys.single, + repository.keys.single, + ]); + } finally { + await subscription.cancel(); + await repository.source.close(); + } + }, + ); + + for (final phase in ['open', 'advance']) { + for (final pauseBeforeFailure in [false, true]) { + test( + 'known data change discards a thrown retired $phase error (paused=$pauseBeforeFailure)', + () async { + final repository = QueryRepositoryFixture(); + final pending = Completer(); + final entered = Completer(); + repository.openHandler = (key) async { + final first = repository.keys.length == 1; + if (first && phase == 'open') { + entered.complete(); + await pending.future; + } + return QuerySessionFixture((_) async { + if (first && phase == 'advance') { + entered.complete(); + await pending.future; + } + return emptyQuery(key); + }); + }; + final seen = []; + final settled = Completer(); + final subscription = + GetNearestUpcomingScheduleUseCase(repository)( + key: queryKey(), + ).listen((query) { + seen.add(query); + if (query is NearestQueryEmpty && !settled.isCompleted) { + settled.complete(); + } + }); + try { + await entered.future; + if (pauseBeforeFailure) { + subscription.pause(); + } + repository.source.add(null); + await Future.delayed(Duration.zero); + pending.completeError(StateError('obsolete $phase read failed')); + if (pauseBeforeFailure) { + await Future.delayed(Duration.zero); + subscription.resume(); + } + await settled.future.timeout(const Duration(seconds: 2)); + expect(seen.whereType(), isEmpty); + expect(repository.keys, hasLength(2)); + expect( + seen.whereType().single.key, + repository.keys.last, + ); + } finally { + await subscription.cancel(); + await repository.source.close(); + } + }, + ); + } + } + + test( + 'a repository failure emits typed recoverable error, never empty', + () async { + final repository = QueryRepositoryFixture() + ..openHandler = (_) async => throw StateError('read failed'); + final useCase = GetNearestUpcomingScheduleUseCase(repository); + final seen = await useCase(key: queryKey()).take(2).toList(); + expect(seen.first, isA()); + expect( + seen.last, + isA().having( + (v) => v.reason, + 'reason', + NearestQueryFailureReason.storeReadFailed, + ), + ); + expect(seen.whereType(), isEmpty); + await repository.source.close(); + }, + ); + + test( + 'bounded chunks auto-progress, yield, and stop at terminal budget', + () async { + final repository = QueryRepositoryFixture(); + late QuerySessionFixture session; + repository.openHandler = (key) async => + session = QuerySessionFixture((budget) async { + expect(budget, 2); + final terminal = session.calls == 3; + return NearestQueryLimited( + key: key, + reason: terminal + ? NearestQueryLimitReason.searchBudgetExhausted + : NearestQueryLimitReason.interrupted, + progress: NearestQueryProgress( + visitedCandidates: session.calls * 2, + candidateBudget: 6, + provenSegments: 0, + totalSegments: 1, + ), + canContinue: !terminal, + canRetry: false, + ); + }); + final seen = await GetNearestUpcomingScheduleUseCase( + repository, + chunkSize: 2, + )(key: queryKey()).take(4).toList(); + expect(seen.whereType(), hasLength(3)); + expect( + seen.last, + isA().having( + (v) => v.canRetry, + 'no budget reset', + false, + ), + ); + expect(session.calls, 3); + expect(session.cancelled, isTrue); + await repository.source.close(); + }, + ); + + test('cancelled subscription drops an old open receipt', () async { + final repository = QueryRepositoryFixture(); + final pending = Completer(); + repository.openHandler = (_) => pending.future; + final seen = []; + final subscription = GetNearestUpcomingScheduleUseCase(repository)( + key: queryKey(), + ).listen(seen.add); + await Future.delayed(Duration.zero); + await subscription.cancel(); + final session = QuerySessionFixture((_) async => emptyQuery(queryKey())); + pending.complete(session); + await Future.delayed(Duration.zero); + expect(seen, hasLength(1)); + expect(session.calls, 0); + expect(session.cancelled, isTrue); + await repository.source.close(); }); + for (final failOld in [false, true]) { + test( + 'known data change rejects retired ${failOld ? "error" : "value"} before publish', + () async { + final repository = QueryRepositoryFixture(); + final pending = Completer(); + final entered = Completer(); + repository.openHandler = (key) async => QuerySessionFixture((_) { + if (repository.keys.length == 1) { + if (!entered.isCompleted) entered.complete(); + return pending.future; + } + return Future.value(emptyQuery(key)); + }); + final seen = []; + final ready = Completer(); + final subscription = + GetNearestUpcomingScheduleUseCase(repository)( + key: queryKey(), + ).listen((v) { + seen.add(v); + if (v is NearestQueryEmpty && !ready.isCompleted) { + ready.complete(); + } + }); + await entered.future; + repository.source.add(null); + await Future.delayed(Duration.zero); + if (failOld) { + pending.complete( + NearestQueryError( + key: repository.keys.first, + reason: NearestQueryFailureReason.preparationReadFailed, + ), + ); + } else { + pending.complete(emptyQuery(repository.keys.first)); + } + await ready.future.timeout(const Duration(seconds: 2)); + expect(seen.whereType(), isEmpty); + expect( + seen.whereType().single.key, + repository.keys.last, + ); + expect(repository.keys, hasLength(2)); + await subscription.cancel(); + await repository.source.close(); + }, + ); + } } diff --git a/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart b/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart index 6379644a..9a8d55cc 100644 --- a/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart +++ b/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart @@ -72,7 +72,7 @@ void main() { expect(draft.id, 'schedule-id'); expect(draft.placeId, 'place-id'); - expect(draft.scheduleTime, DateTime(2027, 4, 5, 8, 31)); + expect(draft.scheduleTime, DateTime.utc(2027, 4, 5, 8, 31)); expect(draft.scheduleSpareTime, const Duration(minutes: 5)); expect(draft.preparation, defaultPreparation); expect(draft.preparationChanged, isFalse); diff --git a/test/domain/use-cases/local_data_workflows_test.dart b/test/domain/use-cases/local_data_workflows_test.dart index db7b3876..4206d7ef 100644 --- a/test/domain/use-cases/local_data_workflows_test.dart +++ b/test/domain/use-cases/local_data_workflows_test.dart @@ -1,3 +1,6 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:async'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -54,6 +57,8 @@ class _DelayedAdapter extends LocalBackupAdapter { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); late AppDatabase db; late BackupService service; late LocalDataOperationGate gate; @@ -70,6 +75,11 @@ void main() { db, _Metadata(), cleanup, + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), operationGate: gate, ); diff --git a/test/domain/use-cases/nearest_schedule_clock_test.dart b/test/domain/use-cases/nearest_schedule_clock_test.dart new file mode 100644 index 00000000..63ae068d --- /dev/null +++ b/test/domain/use-cases/nearest_schedule_clock_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import '../../helpers/fake_clock.dart'; +import '../../helpers/nearest_query_fixture.dart'; +import 'get_nearest_upcoming_schedule_use_case_test.dart' + show QueryRepositoryFixture, QuerySessionFixture, queryKey, emptyQuery; + +void main() { + fakeClockTest( + 'candidate expiry triggers a new bounded query without a data write', + (tester) async { + var now = DateTime.utc(2030, 1, 1, 12); + final target = now.add(const Duration(seconds: 10)); + final schedule = ScheduleWithPreparationEntity( + id: 'first', + place: const PlaceEntity(id: 'p', placeName: 'Office'), + scheduleName: 'First', + scheduleTime: target, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparation: const PreparationWithTimeEntity(preparationStepList: []), + ); + final repository = QueryRepositoryFixture(); + repository.openHandler = (key) async => QuerySessionFixture( + (_) async => now.isAfter(target) + ? emptyQuery(key) + : nearestQueryFixture(schedule, key), + ); + final seen = []; + final subscription = GetNearestUpcomingScheduleUseCase( + repository, + now: () => now, + )(key: queryKey()).listen(seen.add); + await tester.pump(); + expect(seen.last, isA()); + now = target.add(const Duration(microseconds: 1)); + await tester.pump(const Duration(seconds: 10, microseconds: 1)); + expect(seen.last, isA()); + expect(repository.keys, hasLength(2)); + await tester.complete(subscription.cancel()); + await tester.complete(repository.source.close()); + }, + ); + + fakeClockTest( + 'pausing stops chunk work and resume retains the same epoch budget', + (tester) async { + final repository = QueryRepositoryFixture(); + late QuerySessionFixture session; + repository.openHandler = (key) async => session = QuerySessionFixture( + (_) async => session.calls == 4 + ? emptyQuery(key) + : NearestQueryLimited( + key: key, + reason: NearestQueryLimitReason.interrupted, + progress: NearestQueryProgress( + visitedCandidates: session.calls, + candidateBudget: 4, + provenSegments: 0, + totalSegments: 1, + ), + canContinue: true, + canRetry: false, + ), + ); + late StreamSubscription subscription; + final seen = []; + subscription = + GetNearestUpcomingScheduleUseCase(repository, chunkSize: 1)( + key: queryKey(), + ).listen((value) { + seen.add(value); + if (value is NearestQueryLoading && + value.progress.visitedCandidates == 1) { + subscription.pause(); + } + }); + await tester.pump(); + final pausedCalls = session.calls; + await tester.pump(const Duration(minutes: 2)); + expect(session.calls, pausedCalls); + subscription.resume(); + await tester.pump(); + expect(session.calls, 4); + expect(repository.keys, hasLength(1)); + expect(seen.last, isA()); + await tester.complete(subscription.cancel()); + await tester.complete(repository.source.close()); + }, + ); +} diff --git a/test/domain/use-cases/reconcile_alarms_use_case_test.dart b/test/domain/use-cases/reconcile_alarms_use_case_test.dart index a23c4a0e..81c7fedc 100644 --- a/test/domain/use-cases/reconcile_alarms_use_case_test.dart +++ b/test/domain/use-cases/reconcile_alarms_use_case_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/time/civil_time_resolver.dart'; import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; import '../../helpers/isolated_alarm_owner.dart'; import 'package:on_time_front/domain/entities/delivery_observation.dart'; @@ -1634,12 +1635,14 @@ ScheduleWithPreparationEntity scheduleWithAlarmAt({ final scheduleTime = preparationStartTime.add( moveTime + spareTime + preparationTime, ); + final civil = CivilTimeResolver.civilTimeAt(scheduleTime, timeZoneId); return ScheduleWithPreparationEntity( id: id, place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: scheduleName ?? 'Schedule $id', timeZoneId: timeZoneId, - scheduleTime: scheduleTime, + scheduleTime: civil, + occurrenceOffsetSeconds: civil.timeZoneOffset.inSeconds, moveTime: moveTime, isChanged: false, isStarted: isStarted, diff --git a/test/domain/use-cases/recurring_alarm_integration_test.dart b/test/domain/use-cases/recurring_alarm_integration_test.dart index bb62ff7d..cb765e46 100644 --- a/test/domain/use-cases/recurring_alarm_integration_test.dart +++ b/test/domain/use-cases/recurring_alarm_integration_test.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import '../../helpers/isolated_alarm_owner.dart'; import 'dart:async'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; @@ -128,6 +130,7 @@ void main() { database: database, timedPreparationRepository: _UnusedTimers(), recurringScheduleRepository: recurring, + now: () => now, ); alarms = AlarmRepositoryImpl( database: database, @@ -145,6 +148,114 @@ void main() { await database.close(); }); + test( + 'null occurrence resolves without saving, survives missing device zone, and freezes atomically at first start', + () async { + final value = ScheduleEntity( + id: 'null-offset', + place: const PlaceEntity(id: 'null-place', placeName: 'Place'), + scheduleName: 'Seoul', + scheduleTime: DateTime.utc(2030, 1, 1, 15), + timeZoneId: 'Asia/Seoul', + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + await schedules.createSchedule(value); + await preparations.createCustomPreparation(_preparation(90), value.id); + final before = (await database.scheduleDao.getScheduleById( + value.id, + )).schedule; + final initialRevision = + (await database + .customSelect('SELECT data_revision FROM users') + .getSingle()) + .read('data_revision'); + final useCase = ReconcileAlarmsUseCase.test( + alarms, + registry, + native, + _Notifications(), + operations: isolatedOwner, + nowProvider: () => now, + timeZoneProvider: () async => + throw StateError('device lookup unavailable'), + ); + await useCase(); + final record = (await registry.loadAll()).single; + // This fixture's persisted profile alarm offset is zero. + expect(record.alarmTime, DateTime.utc(2030, 1, 1, 4, 30)); + final reloaded = await schedules.getScheduleById(value.id); + expect(reloaded.occurrenceOffsetSeconds, isNull); + await preparations.getPreparationByScheduleId(value.id); + final preparation = (await preparations.preparationStream.firstWhere( + (values) => values.containsKey(value.id), + ))[value.id]!; + final combined = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + reloaded, + PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve(reloaded, nowUtc: now), + ); + expect(record.scheduleFingerprint, combined.cacheFingerprint); + expect( + (await database + .customSelect('SELECT data_revision FROM users') + .getSingle()) + .read('data_revision'), + initialRevision, + ); + now = DateTime.utc(2030, 1, 1, 4, 30); + final firstStart = await schedules.startSchedule( + value.id, + startedAt: now, + ); + final started = await schedules.getScheduleById(value.id); + final startedRow = (await database.scheduleDao.getScheduleById( + value.id, + )).schedule; + expect(started.occurrenceOffsetSeconds, 32400); + expect(started.preparationFrozen, isTrue); + expect(started.startedAt?.toUtc(), firstStart.toUtc()); + expect(startedRow.aggregateVersion, before.aggregateVersion! + 1); + final afterFingerprint = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + started, + PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve(started, nowUtc: now), + ).cacheFingerprint; + expect(afterFingerprint, record.scheduleFingerprint); + final committedRevision = + (await database + .customSelect('SELECT data_revision FROM users') + .getSingle()) + .read('data_revision'); + expect(committedRevision, initialRevision + 1); + now = DateTime.utc(2030, 1, 2); + expect( + await schedules.startSchedule(value.id, startedAt: now), + firstStart, + ); + expect( + (await schedules.getScheduleById(value.id)).occurrenceInstantUtc, + DateTime.utc(2030, 1, 1, 6), + ); + expect( + (await database + .customSelect('SELECT data_revision FROM users') + .getSingle()) + .read('data_revision'), + committedRevision, + ); + await useCase(); + expect(await registry.loadAll(), isEmpty); + expect(native.scheduled, hasLength(1)); + expect(native.canceled.single.scheduleId, value.id); + }, + ); + test( 'actual create/update entrypoints rerun after snapshot and retain nearest 60 without materialize loop', () async { diff --git a/test/domain/use-cases/schedule_date_use_cases_test.dart b/test/domain/use-cases/schedule_date_use_cases_test.dart index 83af7831..71e598fb 100644 --- a/test/domain/use-cases/schedule_date_use_cases_test.dart +++ b/test/domain/use-cases/schedule_date_use_cases_test.dart @@ -158,7 +158,8 @@ ScheduleEntity _schedule(String id, DateTime scheduleTime) { id: id, place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: id, - scheduleTime: scheduleTime, + scheduleTime: scheduleTime.toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 10), isChanged: false, isStarted: false, diff --git a/test/domain/use-cases/schedule_deletion_use_case_test.dart b/test/domain/use-cases/schedule_deletion_use_case_test.dart new file mode 100644 index 00000000..35563c18 --- /dev/null +++ b/test/domain/use-cases/schedule_deletion_use_case_test.dart @@ -0,0 +1,506 @@ +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'dart:async'; +import 'dart:io'; +import 'package:drift/drift.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/services/alarm_journal_store_native.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import '../../helpers/schedule_deletion_workflow_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late DeletionWorkflowFixture f; + late Directory directory; + String? failingCheckpoint; + Future Function(String)? checkpointAction; + setUp(() async { + directory = await Directory.systemTemp.createTemp('u01-journal-'); + failingCheckpoint = null; + checkpointAction = null; + f = DeletionWorkflowFixture( + journalStore: FileAlarmJournalStore( + () async => directory, + checkpoint: (stage) async { + if (stage == failingCheckpoint) { + throw const FileSystemException('injected persistence failure'); + } + await checkpointAction?.call(stage); + }, + ), + ); + await f.open(); + await f.create(); + f.registry.records = [f.record('one', 11)]; + }); + tearDown(() async { + await f.close(); + await directory.delete(recursive: true); + }); + + test( + 'prepare and abandoning confirmation have no DB/runtime/journal effects', + () async { + final revision = await f.revision(); + await f.deletion.prepare('one'); + expect(await f.revision(), revision); + expect(await directory.list().toList(), isEmpty); + expect(f.registry.writes, 0); + expect(f.timed.cleared, isEmpty); + expect(f.fallback.cancelled, isEmpty); + }, + ); + + for (final stage in ['written', 'readBack']) { + test('actual file $stage failure cannot commit deletion', () async { + final intent = await f.deletion.prepare('one'); + final revision = await f.revision(); + failingCheckpoint = stage; + await expectLater( + f.deletion.confirm(intent), + throwsA(isA()), + ); + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await f.revision(), revision); + expect(f.timed.cleared, isEmpty); + expect(f.fallback.cancelled, isEmpty); + }); + } + + test( + 'pending provider keeps owner while committed row stays deleted and journal contains only identity', + () async { + final entered = Completer(), release = Completer(); + f.fallback.onCancel = (_) async { + entered.complete(); + await release.future; + }; + final intent = await f.deletion.prepare('one'); + ScheduleDeletionCommit? receipt; + final future = f.deletion.confirm( + intent, + onCommitted: (value) => receipt = value, + ); + // Observe completion immediately so an early failure cannot become an + // unhandled error while the test is waiting for the provider barrier. + final drained = future.then( + (_) {}, + onError: (Object _, StackTrace __) {}, + ); + Future? queued; + Future? queuedDrained; + try { + expect(identical(future, f.deletion.confirm(intent)), isTrue); + await Future.any([ + entered.future, + future.then( + (_) => + throw StateError('Deletion completed before provider barrier'), + ), + ]); + expect(receipt?.changed, isTrue); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + final raw = await File( + '${directory.path}/ownership-v1.json', + ).readAsString(); + expect(raw, contains('one')); + expect(raw, isNot(contains('private'))); + var nextOwnerRan = false; + queued = f.owner.run(f.owner.capture(), () async { + nextOwnerRan = true; + }); + queuedDrained = queued.then( + (_) {}, + onError: (Object _, StackTrace __) {}, + ); + await pumpEventQueue(); + expect(nextOwnerRan, isFalse); + release.complete(); + expect((await future).cleanup, ScheduleDeletionCleanup.complete); + await queued; + expect(nextOwnerRan, isTrue); + expect(f.fallback.cancelled, hasLength(1)); + expect((await f.owner.journal.read()).ownership, isEmpty); + } finally { + if (!release.isCompleted) release.complete(); + // Drain rather than replace a failed assertion with a secondary cleanup + // error. The normal path above still asserts both operation outcomes. + await drained; + await queuedDrained; + } + }, + ); + + test( + 'provider failure rehydrates actual file journal under new owner and retries cleanup only', + () async { + f.fallback.onCancel = (_) async { + throw StateError('provider failed'); + }; + final intent = await f.deletion.prepare('one'); + final result = await f.deletion.confirm( + intent, + onCommitted: (_) => throw StateError('observer failed'), + ); + expect(result.cleanup, ScheduleDeletionCleanup.pending); + final revision = await f.revision(); + final rehydrated = AlarmOperationCoordinator( + f.gate, + journal: AlarmOwnershipJournal( + FileAlarmJournalStore(() async => directory), + ), + ); + final sessions = f.makeSessions(rehydrated); + addTearDown(() { + sessions.dispose(); + rehydrated.dispose(); + }); + f.registry.records = + []; // independent durable ownership survives lost registry projection + f.fallback.onCancel = null; + final newUseCase = f.makeDeletion(rehydrated, sessions); + expect((await rehydrated.journal.read()).ownership, hasLength(1)); + final retried = await newUseCase.retryCleanup(result.commit); + expect(retried.cleanup, ScheduleDeletionCleanup.complete); + expect(await f.revision(), revision); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect((await rehydrated.journal.read()).ownership, isEmpty); + }, + ); + + test( + 'a different confirmation already deleted the row: absence receipt does not claim a new commit', + () async { + final first = await f.deletion.prepare('one'); + final second = await f.deletion.prepare('one'); + await f.deletion.confirm(second); + final revision = await f.revision(); + final result = await f.deletion.confirm(first); + expect(result.commit.alreadyAbsent, isTrue); + expect(result.commit.changed, isFalse); + expect(await f.revision(), revision); + expect(f.fallback.cancelled, hasLength(1)); + }, + ); + + test( + 'following selected disappears before durable write ends: other occurrence ownership stays armed', + () async { + final start = DateTime.utc(2030, 1, 2, 10); + await f.recurring.create( + f.schedule('series').copyWith(scheduleTime: start), + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'series-step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 1), + ), + ], + ), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 3, + ), + ); + final rows = + (await f.db.select(f.db.schedules).get()) + .where((v) => v.recurringSegmentId != null) + .toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + final selected = rows.first.id; + final others = rows.skip(1).map((v) => v.id).toSet(); + f.registry.records = [ + for (var i = 0; i < rows.length; i++) f.record(rows[i].id, 30 + i), + ]; + final intent = await f.deletion.prepare( + selected, + scope: RecurringEditScope.following, + ); + final direct = await f.aggregate.readForDeletion(selected); + var consumed = false; + checkpointAction = (stage) async { + if (stage == 'readBack' && !consumed) { + consumed = true; + await f.aggregate.delete(direct); + } + }; + final result = await f.deletion.confirm(intent); + expect(result.commit.alreadyAbsent, isTrue); + expect(result.commit.changed, isFalse); + expect( + (await f.db.select(f.db.schedules).get()).map((v) => v.id), + containsAll(others), + ); + expect(f.fallback.cancelled.map((v) => v.scheduleId), [selected]); + final journal = await f.owner.journal.read(); + expect( + journal.ownership.values.map((v) => v.record.scheduleId).toSet(), + others, + ); + expect(journal.ownership.values.every((v) => !v.pending), isTrue); + expect(f.registry.records.every((v) => !v.cancellationPending), isTrue); + }, + ); + + for (final boundary in ['runtime', 'provider']) { + test( + 'same generation same ID recreation during $boundary wait revokes later cleanup authority', + () async { + final entered = Completer(), release = Completer(); + if (boundary == 'runtime') { + f.timed.onClear = (_) async { + entered.complete(); + await release.future; + }; + } else { + f.fallback.onCancel = (_) async { + entered.complete(); + await release.future; + }; + } + final intent = await f.deletion.prepare('one'); + final deleting = f.deletion.confirm(intent); + final rejected = expectLater( + deleting, + throwsA(isA()), + ); + final drained = rejected.then( + (_) {}, + onError: (Object _, StackTrace __) {}, + ); + try { + await Future.any([ + entered.future, + deleting.then( + (_) => throw StateError( + 'Deletion completed before $boundary barrier', + ), + ), + ]); + await f.create(); + await f.early.markStarted( + scheduleId: 'one', + startedAt: DateTime.utc(2029), + ); + final writes = f.registry.writes; + final journalBefore = await File( + '${directory.path}/ownership-v1.json', + ).readAsString(); + release.complete(); + await rejected; + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(f.early.values, contains('one')); + expect(f.registry.writes, writes); + expect( + await File('${directory.path}/ownership-v1.json').readAsString(), + journalBefore, + ); + if (boundary == 'runtime') expect(f.fallback.cancelled, isEmpty); + } finally { + if (!release.isCompleted) release.complete(); + await drained; + } + }, + ); + } + + test( + 'held same-intent receipt retries cleanup without another DB revision', + () async { + final intent = await f.deletion.prepare('one'); + final first = await f.deletion.confirm(intent); + final revision = await f.revision(); + final second = await f.deletion.confirm(intent); + expect(identical(first.commit, second.commit), isTrue); + expect(await f.revision(), revision); + expect(f.fallback.cancelled, hasLength(1)); + }, + ); + + test( + 'unknown ownership remains unconfirmed despite known provider cancellation', + () async { + await f.owner.journal.save(AlarmJournalSnapshot(unknownOwnership: true)); + final result = await f.deletion.confirm(await f.deletion.prepare('one')); + expect(result.cleanup, ScheduleDeletionCleanup.unconfirmed); + expect(result.commit.changed, isTrue); + expect((await f.owner.journal.read()).unknownOwnership, isTrue); + }, + ); + + test( + 'generation replacement after prepare rejects without persisting ownership', + () async { + final intent = await f.deletion.prepare('one'); + await f.gate.run(() async {}, replacesData: true); + await expectLater( + f.deletion.confirm(intent), + throwsA(isA()), + ); + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await directory.list().toList(), isEmpty); + }, + ); + + for (final recurring in [false, true]) { + test( + 'valid durable early preparation blocks deletion with recurring=$recurring and isStarted=false', + () async { + var id = 'one'; + if (recurring) { + final start = DateTime.utc(2030, 1, 2, 10); + await f.recurring.create( + f.schedule('series').copyWith(scheduleTime: start), + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'series-step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 1), + ), + ], + ), + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 2, + ), + ); + id = (await f.db.select(f.db.schedules).get()) + .firstWhere((row) => row.recurringSegmentId != null) + .id; + } + final beforeRows = await f.db.select(f.db.schedules).get(); + final beforeRevision = await f.revision(); + final snapshot = await f.aggregate.readForEdit(id); + final preparation = PreparationWithTimeEntity.fromPreparation( + snapshot.preparation, + ); + final value = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + snapshot.schedule, + preparation, + timeResolution: ScheduleTimeResolver.resolve( + snapshot.schedule, + nowUtc: f.now, + ), + ); + f.timed.snapshots[id] = TimedPreparationSnapshotEntity( + preparation: preparation, + savedAt: f.now, + scheduleFingerprint: value.cacheFingerprint, + startedAt: f.now, + ); + await expectLater( + f.deletion.prepare(id), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + ScheduleDeletionFailure.protected, + ), + ), + ); + expect(await f.db.select(f.db.schedules).get(), beforeRows); + expect(await f.revision(), beforeRevision); + expect(f.fallback.cancelled, isEmpty); + expect(f.scheduler.cancelled, isEmpty); + expect(f.timed.snapshots, contains(id)); + expect(await directory.list().toList(), isEmpty); + }, + ); + } + + test( + 'native cancellation failure retains deletion and retries only old ownership', + () async { + f.registry.records = [ + ScheduledAlarmRecord( + scheduleId: 'one', + provider: AlarmProvider.androidAlarmManager, + nativeAlarmId: 73, + alarmTime: DateTime.utc(2030), + preparationStartTime: DateTime.utc(2030), + scheduleFingerprint: 'private native fingerprint', + scheduleTitle: 'private native title', + payload: const {'note': 'private native payload'}, + ), + ]; + f.scheduler.onCancel = (_) async { + throw StateError('native unavailable'); + }; + final result = await f.deletion.confirm(await f.deletion.prepare('one')); + expect(result.cleanup, ScheduleDeletionCleanup.pending); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + final revision = await f.revision(); + final ownership = (await f.owner.journal.read()).ownership.values.single; + expect(ownership.pending, isTrue); + expect(ownership.record.nativeAlarmId, 73); + expect( + await File('${directory.path}/ownership-v1.json').readAsString(), + isNot(contains('private')), + ); + f.scheduler.onCancel = null; + final retried = await f.deletion.retryCleanup(result.commit); + expect(retried.cleanup, ScheduleDeletionCleanup.complete); + expect(await f.revision(), revision); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(f.scheduler.cancelled.map((record) => record.nativeAlarmId), [ + 73, + 73, + ]); + expect(f.fallback.cancelled, isEmpty); + expect((await f.owner.journal.read()).ownership, isEmpty); + }, + ); + + test( + 'started row and orphan early state are blocked without mutation', + () async { + await (f.db.update(f.db.schedules)..where((v) => v.id.equals('one'))) + .write(const SchedulesCompanion(isStarted: Value(true))); + await expectLater( + f.deletion.prepare('one'), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + ScheduleDeletionFailure.protected, + ), + ), + ); + await (f.db.update(f.db.schedules)..where((v) => v.id.equals('one'))) + .write(const SchedulesCompanion(isStarted: Value(false))); + await f.early.markStarted( + scheduleId: 'one', + startedAt: DateTime.utc(2029), + ); + await expectLater( + f.deletion.prepare('one'), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + ScheduleDeletionFailure.unavailable, + ), + ), + ); + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await directory.list().toList(), isEmpty); + }, + ); +} diff --git a/test/domain/use-cases/schedule_mutation_use_cases_test.dart b/test/domain/use-cases/schedule_mutation_use_cases_test.dart index 1390b094..7fdeb062 100644 --- a/test/domain/use-cases/schedule_mutation_use_cases_test.dart +++ b/test/domain/use-cases/schedule_mutation_use_cases_test.dart @@ -3,7 +3,7 @@ import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/repositories/schedule_repository.dart'; import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; -import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import '../../helpers/schedule_deletion_workflow_fixture.dart'; import 'package:on_time_front/domain/use-cases/finish_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; import 'package:on_time_front/domain/use-cases/start_schedule_use_case.dart'; @@ -67,17 +67,18 @@ void main() { ); test( - 'delete schedule removes schedule then requests deleted alarm effects', + 'confirmed delete persists once and requests reconciliation after cleanup', () async { - final scheduleRepository = _FakeScheduleRepository(); - final alarmEffects = _FakeScheduleMutationAlarmEffectsCoordinator(); - final useCase = DeleteScheduleUseCase(scheduleRepository, alarmEffects); - final schedule = _schedule('schedule-1'); - - await useCase(schedule); - - expect(scheduleRepository.deletedSchedules, [schedule]); - expect(alarmEffects.calls, ['deleted:schedule-1']); + final fixture = DeletionWorkflowFixture(); + addTearDown(fixture.close); + await fixture.open(); + await fixture.create(); + final before = await fixture.revision(); + await fixture.deletion.confirm(await fixture.deletion.prepare('one')); + await pumpEventQueue(); + expect(await fixture.db.select(fixture.db.schedules).get(), isEmpty); + expect(await fixture.revision(), before + 1); + expect(fixture.reconciliation.callCount, 1); }, ); @@ -163,6 +164,8 @@ class _FakeScheduleRepository implements ScheduleRepository { Future startSchedule( String scheduleId, { DateTime? startedAt, + bool Function()? isCurrent, + String? expectedFingerprint, }) async { startedScheduleIds.add(scheduleId); return startedAt ?? DateTime.utc(2026); diff --git a/test/domain/use-cases/schedule_preparation_session_use_case_test.dart b/test/domain/use-cases/schedule_preparation_session_use_case_test.dart index fab93df5..22e4359a 100644 --- a/test/domain/use-cases/schedule_preparation_session_use_case_test.dart +++ b/test/domain/use-cases/schedule_preparation_session_use_case_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; @@ -500,6 +501,10 @@ void main() { ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now().toUtc(), + ), ); scheduleRepository.schedulesById[schedule.id] = schedule; preparationRepository.preparationsById[schedule.id] = preparation; @@ -665,6 +670,10 @@ ScheduleWithPreparationEntity _scheduleWithPreparation(String id) { ), ], ), + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now().toUtc(), + ), ); } @@ -678,6 +687,7 @@ ScheduleEntity _scheduleEntity( place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Meeting', scheduleTime: DateTime.utc(2026, 6, 28, 9), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 10), isChanged: false, isStarted: false, @@ -723,6 +733,8 @@ class _FakeScheduleRepository implements ScheduleRepository { Future startSchedule( String scheduleId, { DateTime? startedAt, + bool Function()? isCurrent, + String? expectedFingerprint, }) async { startedScheduleIds.add(scheduleId); requestedTimes.add(startedAt); diff --git a/test/domain/use-cases/timed_preparation_use_cases_test.dart b/test/domain/use-cases/timed_preparation_use_cases_test.dart index 61f832d4..6832781f 100644 --- a/test/domain/use-cases/timed_preparation_use_cases_test.dart +++ b/test/domain/use-cases/timed_preparation_use_cases_test.dart @@ -17,7 +17,6 @@ import 'package:on_time_front/domain/repositories/early_start_session_repository import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; import 'package:on_time_front/domain/use-cases/clear_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/clear_timed_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; @@ -27,7 +26,6 @@ import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case. import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_schedules_by_date_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_schedules_for_week_use_case.dart'; import 'package:on_time_front/domain/use-cases/mark_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/stream_preparations_use_case.dart'; @@ -237,55 +235,6 @@ void main() { }); }, ); - - test( - 'GetNearestUpcomingScheduleUseCase loads prep for nearest active schedule', - () async { - final now = DateTime.now(); - final nearest = _schedule('nearest', now.add(const Duration(hours: 1))); - final ended = _schedule( - 'ended', - now.add(const Duration(minutes: 30)), - ).copyWith(doneStatus: ScheduleDoneStatus.normalEnd); - final later = _schedule('later', now.add(const Duration(hours: 2))); - final scheduleRepository = _FakeScheduleRepository({ - later, - ended, - nearest, - }); - final preparationRepository = _FakePreparationRepository() - ..emit({ - 'nearest': const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'prep-1', - preparationName: 'Pack', - preparationTime: Duration(minutes: 5), - ), - ], - ), - }); - final loadSchedulesByDate = LoadSchedulesByDateUseCase( - scheduleRepository, - ); - final useCase = GetNearestUpcomingScheduleUseCase( - GetSchedulesByDateUseCase(scheduleRepository), - LoadPreparationByScheduleIdUseCase(preparationRepository), - GetPreparationByScheduleIdUseCase(preparationRepository), - LoadSchedulesForWeekUseCase(loadSchedulesByDate), - ); - - final schedule = await useCase().first; - - expect(schedule!.id, 'nearest'); - expect(preparationRepository.loadedScheduleIds, ['nearest']); - expect(schedule.preparation.preparationStepList.single.id, 'prep-1'); - expect( - scheduleRepository.requestedRanges.length, - greaterThanOrEqualTo(1), - ); - }, - ); } class _FakeTimedPreparationRepository implements TimedPreparationRepository { @@ -387,6 +336,8 @@ class _FakeScheduleRepository implements ScheduleRepository { Future startSchedule( String scheduleId, { DateTime? startedAt, + bool Function()? isCurrent, + String? expectedFingerprint, }) async => startedAt ?? DateTime.utc(2026); @override @@ -494,7 +445,8 @@ ScheduleEntity _schedule(String id, DateTime scheduleTime) { id: id, place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: scheduleTime, + scheduleTime: scheduleTime.toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 10), isChanged: false, isStarted: false, @@ -510,6 +462,7 @@ ScheduleWithPreparationEntity _scheduleWithPreparation(String id) { place: schedule.place, scheduleName: schedule.scheduleName, scheduleTime: schedule.scheduleTime, + occurrenceOffsetSeconds: schedule.occurrenceOffsetSeconds, moveTime: schedule.moveTime, isChanged: schedule.isChanged, isStarted: schedule.isStarted, diff --git a/test/fixtures/database/README.md b/test/fixtures/database/README.md index 685bc904..26f806de 100644 --- a/test/fixtures/database/README.md +++ b/test/fixtures/database/README.md @@ -1,12 +1,22 @@ -# Historical SQLite fixtures for A08 +# Independent historical schemas and synthetic durable row images -`schema_v2.sql` was executed and emitted from actual Drift AppDatabase sources and matching generated artifacts at commit `4529d9f84e911b1ee2fbb42af09698a0c27cc9f2`, copied to `/tmp/a08-schema-baseline`. The extraction test opened `NativeDatabase.memory()`, queried `SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type DESC,name`, joined DDL with semicolons and appended `PRAGMA user_version=2`. Command: `/Users/ejunpark/Library/flutter/bin/flutter test test/dump_test.dart` in the isolated temporary source copy; execution log `/tmp/a08-historical-dump.log`. +These are local-only app schemas. They are not a server database, a backup wire format, or output automatically rewritten from today's entities. -`schema_v1.sql` was **reconstructed from that frozen v2 DDL**, removing the four recurring tables and five schedule columns introduced by commit `f0877d1df9aa62528f360f5ee1d3bbb095e2f691`, and setting `user_version=1`. It was checked against the source definitions at `ccc6272bde15937a87dd577157b3330593718759` with `git diff ccc6272bde15937a87dd577157b3330593718759 4529d9f84e911b1ee2fbb42af09698a0c27cc9f2 -- lib/data/tables lib/core/database/database.dart`. Other historical table definitions are unchanged. It was not emitted by running a historical v1 binary. +| Version | Source commit | Independent extraction | +|---|---|---| +| 1 | `ccc6272bde15937a87dd577157b3330593718759` | D04 executed unchanged historical `lib/`, pubspec and lock in `/tmp/ontime-d04-history-v1`. Its output is byte-identical to A08's earlier reverse-reconstructed v1 fixture. That agreement corroborates the reconstruction; the new provenance is independent historical source execution. | +| 2 | `4529d9f84e911b1ee2fbb42af09698a0c27cc9f2` | A08 executed the frozen Drift database in `/tmp/a08-schema-baseline`; `/tmp/a08-historical-dump.log` records the extraction. | +| 3 | `126161dab042365c89983934b1673d860c27d8d3` | D04 executed unchanged historical sources in `/tmp/ontime-d04-history-v3`. | +| 4 | `e0a668f8c84edc54f4c7501399409e6a79d6510b` | D04 emitted the current schema before modifying migration behavior. No schema bump is introduced by D04. | -The migration tests populate these frozen schemas using sqlite3, close them, then open file databases through the current AppDatabase migration path. They verify v1/v2 migration, rollback, identity and protected data preservation, and future-version refusal. This is native SQLite testing, not a historical released app or device SQLCipher migration claim; D04 owns that device verification. Fixtures never derive from current schema-3 table models. +Historical source copies use the current installed Flutter/Dart toolchain and the frozen dependency lock. Ignored generated Drift outputs are regenerated with `dart run build_runner build --delete-conflicting-outputs`; these are adapted source executions, not historical released binaries. v3 required its own `flutter pub get --offline` to regenerate package metadata after copied metadata failed; no historical product source or lock changed. Root independently checked 303 v1 and 353 v3 non-generated source files against their commit blobs, with zero differences. -SHA-256: +The historical extraction command was `flutter test test/dump_test.dart --no-pub --reporter expanded` in each temporary source checkout. Current reusable extraction is `ONTIME_SCHEMA_OUTPUT=/tmp/explicit-output.sql flutter test tool/qa/emit_database_schema.dart --no-pub`; it only writes the explicitly selected temporary destination. Do not run it over historical fixtures. The original emit query sorted `sqlite_master` by type descending; v3/v4 therefore emitted triggers before tables. Executable fixtures stably reorder whole SQLite objects to tables, indexes, triggers, then `PRAGMA user_version`. Trigger bodies are indivisible; no semicolon splitting of `BEGIN ... END` occurs. Original emitted statement text and duplicate-aware multiset are unchanged. Category-internal order is preserved. -- `schema_v1.sql`: `782cd3a3cfdca56c67f896a7fbdfa9791a349f77b2b65e21c7f0c0f2ce4a121d` -- `schema_v2.sql`: `a7ec3c57f1c86a58ef45c3417abe187fa4e44a0be87e3861a7182cb3ab02d7c5` +Exact original and executable hashes, statement counts, source verification, sanitized execution logs and metadata adaptation are archived under `plans/audit-2026-09-23/artifacts/d04/history/`. In particular, original v3 SHA `e2a277d4cef6f991b940828655bf72971ab6cfd7ba52de685432ffe500d82680` is not the reordered executable fixture hash. `ddl-provenance.json` records both. Source emit used SQLite 3.51.0; descriptor extraction's separate engine is recorded in `metadata-adaptation.json`. The builder actually executes each frozen DDL under the locked Dart SQLite engine and compares every object and PRAGMA descriptor before embedding the contract. + +`populated_local.sql` inserts fixed synthetic values into all seven v1 tables: Korean text, significant spaces, nulls, completed/progress facts, frozen preparation, score counters, revisions, chain relationships, and timezone/offset boundaries. `populated_recurring.sql` populates all four additional tables and relationships for v2+. Historical row-image tests project the original columns and compare every value after migration and independent reopen; a second reopen compares the complete current row image, including generated identity/revisions. Current entity serializers do not construct the historical baseline. + +`schema_contracts.schema.json` declares current and supported lineages. Run the normal generator, then `dart run tool/check_database_schema.dart`. The gate fails for missing, duplicate, noninteger or unsupported version declarations, inconsistent actual DDL/descriptors, changed fixture hashes, and stale generated input digest. Migration tests iterate the declared legacy lineage through the actual current AppDatabase, so a future bump without a working predecessor path fails CI. Never automatically overwrite frozen snapshots to make that test pass. + +Evidence scopes are separate: plain-host SQLite behavior, actual host SQLCipher, dedicated iOS/Android QA bundle, and physical-device behavior. Process SIGKILL checkpoints prove observed process-death/reopen behavior only, not power-loss or fsync guarantees. Migration rollback is judged by logical schema/data/version and preserved key identity/file family; whole-file byte identity is required for readonly refusals, not arbitrary successful journal recovery. diff --git a/test/fixtures/database/populated_local.sql b/test/fixtures/database/populated_local.sql new file mode 100644 index 00000000..04b35d58 --- /dev/null +++ b/test/fixtures/database/populated_local.sql @@ -0,0 +1,11 @@ +-- Synthetic durable row images, independent of today's entity serializers. +INSERT INTO users(id,spare_time,note,is_onboarding_completed,eligible_outcome_count,on_time_outcome_count,alarms_enabled,alarm_offset_minutes,detailed_notification_content,data_revision,last_exported_revision,last_exported_at,first_durable_data_at,last_durable_data_at) +VALUES('local-profile',17,'합성 프로필 공백',1,4,3,1,-5,0,19,17,100,50,200); +INSERT INTO places(id,place_name) VALUES('place-a','집'),('place-b',' New York '); +INSERT INTO preparation_templates(id,template_name,created_at,updated_at) VALUES('template-a','아침 준비',100,150); +INSERT INTO preparation_template_steps(id,template_id,preparation_name,preparation_time,position) VALUES('ts-a','template-a','옷 입기',7,0),('ts-b','template-a','나가기',0,1); +INSERT INTO preparation_users(id,user_id,preparation_name,preparation_time,next_preparation_id) VALUES('pu-b','local-profile','마지막',0,NULL),('pu-a','local-profile','처음',11,'pu-b'); +INSERT INTO schedules(id,place_id,schedule_name,time_zone_id,occurrence_offset_seconds,schedule_time,move_time,is_changed,is_started,schedule_spare_time,schedule_note,lateness_time,done_status,started_at,finished_at,preparation_mode,preparation_template_id,preparation_template_name,preparation_template_deleted,preparation_frozen,score_contribution_recorded) +VALUES('done','place-a','완료된 합성 일정','Asia/Seoul',NULL,'2026-01-01T09:15:00.000',13,1,1,0,NULL,4,'normalEnd',100,200,'custom','template-a','삭제된 원본 이름',1,1,1), +('future','place-b','중첩 시각 일정','America/New_York',-14400,'2030-11-03T01:30:00.000',9,0,0,NULL,' 두 공백 유지 ', -1,'notEnded',NULL,NULL,'default',NULL,NULL,0,0,0); +INSERT INTO preparation_schedules(id,schedule_id,preparation_name,preparation_time,next_preparation_id) VALUES('ps-b','done','종료 단계',0,NULL),('ps-a','done','첫 단계',12,'ps-b'); diff --git a/test/fixtures/database/populated_recurring.sql b/test/fixtures/database/populated_recurring.sql new file mode 100644 index 00000000..42eabaa7 --- /dev/null +++ b/test/fixtures/database/populated_recurring.sql @@ -0,0 +1,6 @@ +INSERT INTO preparation_definitions(id,owner_id,scope,name,created_at) VALUES('definition','series','recurring','반복 준비',100); +INSERT INTO preparation_definition_steps(id,definition_id,name,minutes,position) VALUES('definition-step','definition','알림 공백',0,0); +INSERT INTO recurring_schedule_segments(id,series_id,rule_json,schedule_json,preparation_id,from_slot,before_slot,created_at,preparation_not_before) +VALUES('root-segment','series','{"type":"synthetic-preserve","days":[1,3]}','{"note":"한글 원문"}','definition','2030-11-03T01:30:00.000',NULL,100,90); +INSERT INTO recurring_schedule_exclusions(segment_id,slot_key,ordinal) VALUES('root-segment','2030-11-10T01:30:00.000',2); +UPDATE schedules SET recurring_segment_id='root-segment',recurring_slot_key='2030-11-03T01:30:00.000',recurring_ordinal=1,recurring_overrides='note',preparation_definition_id='definition' WHERE id='future'; diff --git a/test/fixtures/database/schema_v3.sql b/test/fixtures/database/schema_v3.sql new file mode 100644 index 00000000..daa26b90 --- /dev/null +++ b/test/fixtures/database/schema_v3.sql @@ -0,0 +1,65 @@ +CREATE TABLE "places" ("id" TEXT NOT NULL, "place_name" TEXT NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_definition_steps" ("id" TEXT NOT NULL, "definition_id" TEXT NOT NULL REFERENCES preparation_definitions (id), "name" TEXT NOT NULL, "minutes" INTEGER NOT NULL CHECK (minutes >= 0), "position" INTEGER NOT NULL CHECK (position >= 0), PRIMARY KEY ("id"), UNIQUE ("definition_id", "position")); +CREATE TABLE "preparation_definitions" ("id" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "scope" TEXT NOT NULL, "name" TEXT NOT NULL, "created_at" INTEGER NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_schedules" ("id" TEXT NOT NULL, "schedule_id" TEXT NOT NULL REFERENCES schedules (id), "preparation_name" TEXT NOT NULL, "preparation_time" INTEGER NOT NULL, "next_preparation_id" TEXT NULL REFERENCES preparation_schedules (id), PRIMARY KEY ("id")); +CREATE TABLE "preparation_template_steps" ("id" TEXT NOT NULL, "template_id" TEXT NOT NULL REFERENCES preparation_templates (id), "preparation_name" TEXT NOT NULL, "preparation_time" INTEGER NOT NULL, "position" INTEGER NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_templates" ("id" TEXT NOT NULL, "template_name" TEXT NOT NULL, "created_at" INTEGER NOT NULL, "updated_at" INTEGER NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_users" ("id" TEXT NOT NULL, "user_id" TEXT NOT NULL REFERENCES users (id), "preparation_name" TEXT NOT NULL, "preparation_time" INTEGER NOT NULL, "next_preparation_id" TEXT NULL REFERENCES preparation_users (id), PRIMARY KEY ("id")); +CREATE TABLE "recurring_schedule_exclusions" ("segment_id" TEXT NOT NULL REFERENCES recurring_schedule_segments (id), "slot_key" TEXT NOT NULL, "ordinal" INTEGER NOT NULL, PRIMARY KEY ("segment_id", "slot_key")); +CREATE TABLE "recurring_schedule_segments" ("root_segment_id" TEXT NULL, "aggregate_incarnation" TEXT NULL, "aggregate_version" INTEGER NULL, "last_mutation_id" TEXT NULL, "last_mutation_digest" TEXT NULL, "last_mutation_version" INTEGER NULL, "id" TEXT NOT NULL, "series_id" TEXT NOT NULL, "rule_json" TEXT NOT NULL, "schedule_json" TEXT NOT NULL, "preparation_id" TEXT NOT NULL REFERENCES preparation_definitions (id), "from_slot" TEXT NOT NULL, "before_slot" TEXT NULL, "created_at" INTEGER NOT NULL, "preparation_not_before" INTEGER NULL, PRIMARY KEY ("id")); +CREATE TABLE "schedules" ("aggregate_incarnation" TEXT NULL, "aggregate_version" INTEGER NULL, "last_mutation_id" TEXT NULL, "last_mutation_digest" TEXT NULL, "last_mutation_version" INTEGER NULL, "id" TEXT NOT NULL, "place_id" TEXT NOT NULL REFERENCES places (id), "schedule_name" TEXT NOT NULL, "time_zone_id" TEXT NOT NULL DEFAULT 'UTC', "occurrence_offset_seconds" INTEGER NULL, "schedule_time" TEXT NOT NULL, "move_time" INTEGER NOT NULL, "is_changed" INTEGER NOT NULL DEFAULT 0 CHECK ("is_changed" IN (0, 1)), "is_started" INTEGER NOT NULL DEFAULT 0 CHECK ("is_started" IN (0, 1)), "schedule_spare_time" INTEGER NULL, "schedule_note" TEXT NULL, "lateness_time" INTEGER NOT NULL DEFAULT -1, "done_status" TEXT NOT NULL DEFAULT 'notEnded', "started_at" INTEGER NULL, "finished_at" INTEGER NULL, "preparation_mode" TEXT NULL, "preparation_template_id" TEXT NULL, "preparation_template_name" TEXT NULL, "preparation_template_deleted" INTEGER NOT NULL DEFAULT 0 CHECK ("preparation_template_deleted" IN (0, 1)), "preparation_frozen" INTEGER NOT NULL DEFAULT 0 CHECK ("preparation_frozen" IN (0, 1)), "score_contribution_recorded" INTEGER NOT NULL DEFAULT 0 CHECK ("score_contribution_recorded" IN (0, 1)), "recurring_segment_id" TEXT NULL, "recurring_slot_key" TEXT NULL, "recurring_ordinal" INTEGER NULL, "recurring_overrides" TEXT NOT NULL DEFAULT '', "preparation_definition_id" TEXT NULL, PRIMARY KEY ("id")); +CREATE TABLE "users" ("store_incarnation" TEXT NULL, "id" TEXT NOT NULL, "spare_time" INTEGER NOT NULL, "note" TEXT NOT NULL, "is_onboarding_completed" INTEGER NOT NULL DEFAULT 0 CHECK ("is_onboarding_completed" IN (0, 1)), "eligible_outcome_count" INTEGER NOT NULL DEFAULT 0, "on_time_outcome_count" INTEGER NOT NULL DEFAULT 0, "alarms_enabled" INTEGER NOT NULL DEFAULT 1 CHECK ("alarms_enabled" IN (0, 1)), "alarm_offset_minutes" INTEGER NOT NULL DEFAULT 0, "detailed_notification_content" INTEGER NOT NULL DEFAULT 0 CHECK ("detailed_notification_content" IN (0, 1)), "data_revision" INTEGER NOT NULL DEFAULT 0, "last_exported_revision" INTEGER NULL, "last_exported_at" INTEGER NULL, "first_durable_data_at" INTEGER NULL, "last_durable_data_at" INTEGER NULL, PRIMARY KEY ("id")); +CREATE INDEX preparation_schedules_next_preparation_id_idx ON preparation_schedules (next_preparation_id); +CREATE INDEX preparation_schedules_schedule_id_idx ON preparation_schedules (schedule_id); +CREATE INDEX preparation_template_steps_template_id_idx ON preparation_template_steps (template_id); +CREATE INDEX preparation_users_next_preparation_id_idx ON preparation_users (next_preparation_id); +CREATE INDEX preparation_users_user_id_idx ON preparation_users (user_id); +CREATE INDEX schedules_place_id_idx ON schedules (place_id); +CREATE INDEX schedules_schedule_time_idx ON schedules (schedule_time); +CREATE TRIGGER definition_steps_delete AFTER DELETE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=OLD.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=OLD.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=OLD.definition_id); END; +CREATE TRIGGER definition_steps_insert AFTER INSERT ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END; +CREATE TRIGGER definition_steps_update AFTER UPDATE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END; +CREATE TRIGGER exclusion_delete AFTER DELETE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.segment_id); END; +CREATE TRIGGER exclusion_insert AFTER INSERT ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END; +CREATE TRIGGER exclusion_update AFTER UPDATE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END; +CREATE TRIGGER place_version AFTER UPDATE OF place_name ON places WHEN NEW.place_name IS NOT OLD.place_name BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE place_id = NEW.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.place_id=NEW.id); END; +CREATE TRIGGER preparation_default_delete AFTER DELETE ON preparation_users BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END; +CREATE TRIGGER preparation_default_insert AFTER INSERT ON preparation_users BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END; +CREATE TRIGGER preparation_default_update AFTER UPDATE ON preparation_users BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END; +CREATE TRIGGER preparation_schedule_delete AFTER DELETE ON preparation_schedules BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = OLD.schedule_id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = OLD.schedule_id)); END; +CREATE TRIGGER preparation_schedule_insert AFTER INSERT ON preparation_schedules BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END; +CREATE TRIGGER preparation_schedule_update AFTER UPDATE ON preparation_schedules BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END; +CREATE TRIGGER recurring_schedule_segments_identity AFTER INSERT ON recurring_schedule_segments + WHEN NEW.aggregate_incarnation IS NULL BEGIN + UPDATE recurring_schedule_segments SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END; +CREATE TRIGGER schedules_deleted AFTER DELETE ON schedules BEGIN + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END; +CREATE TRIGGER schedules_identity AFTER INSERT ON schedules + WHEN NEW.aggregate_incarnation IS NULL BEGIN + UPDATE schedules SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END; +CREATE TRIGGER schedules_version AFTER UPDATE OF place_id,schedule_name,time_zone_id,occurrence_offset_seconds,schedule_time,move_time,is_changed,is_started,schedule_spare_time,schedule_note,lateness_time,done_status,started_at,finished_at,preparation_mode,preparation_template_id,preparation_template_name,preparation_template_deleted,preparation_frozen,score_contribution_recorded,recurring_segment_id,recurring_slot_key,recurring_ordinal,recurring_overrides,preparation_definition_id ON schedules + WHEN NEW.place_id IS NOT OLD.place_id OR NEW.schedule_name IS NOT OLD.schedule_name OR NEW.time_zone_id IS NOT OLD.time_zone_id OR NEW.occurrence_offset_seconds IS NOT OLD.occurrence_offset_seconds OR NEW.schedule_time IS NOT OLD.schedule_time OR NEW.move_time IS NOT OLD.move_time OR NEW.is_changed IS NOT OLD.is_changed OR NEW.is_started IS NOT OLD.is_started OR NEW.schedule_spare_time IS NOT OLD.schedule_spare_time OR NEW.schedule_note IS NOT OLD.schedule_note OR NEW.lateness_time IS NOT OLD.lateness_time OR NEW.done_status IS NOT OLD.done_status OR NEW.started_at IS NOT OLD.started_at OR NEW.finished_at IS NOT OLD.finished_at OR NEW.preparation_mode IS NOT OLD.preparation_mode OR NEW.preparation_template_id IS NOT OLD.preparation_template_id OR NEW.preparation_template_name IS NOT OLD.preparation_template_name OR NEW.preparation_template_deleted IS NOT OLD.preparation_template_deleted OR NEW.preparation_frozen IS NOT OLD.preparation_frozen OR NEW.score_contribution_recorded IS NOT OLD.score_contribution_recorded OR NEW.recurring_segment_id IS NOT OLD.recurring_segment_id OR NEW.recurring_slot_key IS NOT OLD.recurring_slot_key OR NEW.recurring_ordinal IS NOT OLD.recurring_ordinal OR NEW.recurring_overrides IS NOT OLD.recurring_overrides OR NEW.preparation_definition_id IS NOT OLD.preparation_definition_id BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END; +CREATE TRIGGER segment_root AFTER INSERT ON recurring_schedule_segments WHEN NEW.root_segment_id IS NULL BEGIN UPDATE recurring_schedule_segments SET root_segment_id = COALESCE((SELECT root_segment_id FROM recurring_schedule_segments WHERE series_id=NEW.series_id AND root_segment_id IS NOT NULL LIMIT 1), NEW.id) WHERE id=NEW.id; END; +CREATE TRIGGER segments_version AFTER UPDATE OF rule_json,schedule_json,preparation_id,from_slot,before_slot,preparation_not_before ON recurring_schedule_segments + WHEN NEW.rule_json IS NOT OLD.rule_json OR NEW.schedule_json IS NOT OLD.schedule_json OR NEW.preparation_id IS NOT OLD.preparation_id OR NEW.from_slot IS NOT OLD.from_slot OR NEW.before_slot IS NOT OLD.before_slot OR NEW.preparation_not_before IS NOT OLD.preparation_not_before BEGIN + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.id); END; +CREATE TRIGGER template_delete AFTER DELETE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END; +CREATE TRIGGER template_steps_delete AFTER DELETE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.template_id); END; +CREATE TRIGGER template_steps_insert AFTER INSERT ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END; +CREATE TRIGGER template_steps_update AFTER UPDATE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END; +CREATE TRIGGER template_update AFTER UPDATE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END; +CREATE TRIGGER users_identity AFTER INSERT ON users WHEN NEW.store_incarnation IS NULL BEGIN + UPDATE users SET store_incarnation = lower(hex(randomblob(16))) WHERE id = NEW.id; END; +PRAGMA user_version=3; diff --git a/test/fixtures/database/schema_v4.sql b/test/fixtures/database/schema_v4.sql new file mode 100644 index 00000000..ff6b21e4 --- /dev/null +++ b/test/fixtures/database/schema_v4.sql @@ -0,0 +1,65 @@ +CREATE TABLE "places" ("id" TEXT NOT NULL, "place_name" TEXT NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_definition_steps" ("id" TEXT NOT NULL, "definition_id" TEXT NOT NULL REFERENCES preparation_definitions (id), "name" TEXT NOT NULL, "minutes" INTEGER NOT NULL CHECK (minutes >= 0), "position" INTEGER NOT NULL CHECK (position >= 0), PRIMARY KEY ("id"), UNIQUE ("definition_id", "position")); +CREATE TABLE "preparation_definitions" ("id" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "scope" TEXT NOT NULL, "name" TEXT NOT NULL, "created_at" INTEGER NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_schedules" ("id" TEXT NOT NULL, "schedule_id" TEXT NOT NULL REFERENCES schedules (id), "preparation_name" TEXT NOT NULL, "preparation_time" INTEGER NOT NULL, "next_preparation_id" TEXT NULL REFERENCES preparation_schedules (id), PRIMARY KEY ("id")); +CREATE TABLE "preparation_template_steps" ("id" TEXT NOT NULL, "template_id" TEXT NOT NULL REFERENCES preparation_templates (id), "preparation_name" TEXT NOT NULL, "preparation_time" INTEGER NOT NULL, "position" INTEGER NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_templates" ("id" TEXT NOT NULL, "template_name" TEXT NOT NULL, "created_at" INTEGER NOT NULL, "updated_at" INTEGER NOT NULL, PRIMARY KEY ("id")); +CREATE TABLE "preparation_users" ("id" TEXT NOT NULL, "user_id" TEXT NOT NULL REFERENCES users (id), "preparation_name" TEXT NOT NULL, "preparation_time" INTEGER NOT NULL, "next_preparation_id" TEXT NULL REFERENCES preparation_users (id), PRIMARY KEY ("id")); +CREATE TABLE "recurring_schedule_exclusions" ("segment_id" TEXT NOT NULL REFERENCES recurring_schedule_segments (id), "slot_key" TEXT NOT NULL, "ordinal" INTEGER NOT NULL, PRIMARY KEY ("segment_id", "slot_key")); +CREATE TABLE "recurring_schedule_segments" ("root_segment_id" TEXT NULL, "aggregate_incarnation" TEXT NULL, "aggregate_version" INTEGER NULL, "last_mutation_id" TEXT NULL, "last_mutation_digest" TEXT NULL, "last_mutation_version" INTEGER NULL, "id" TEXT NOT NULL, "series_id" TEXT NOT NULL, "rule_json" TEXT NOT NULL, "schedule_json" TEXT NOT NULL, "preparation_id" TEXT NOT NULL REFERENCES preparation_definitions (id), "from_slot" TEXT NOT NULL, "before_slot" TEXT NULL, "created_at" INTEGER NOT NULL, "preparation_not_before" INTEGER NULL, PRIMARY KEY ("id")); +CREATE TABLE "schedules" ("requires_start_confirmation" INTEGER NOT NULL DEFAULT 0 CHECK ("requires_start_confirmation" IN (0, 1)), "aggregate_incarnation" TEXT NULL, "aggregate_version" INTEGER NULL, "last_mutation_id" TEXT NULL, "last_mutation_digest" TEXT NULL, "last_mutation_version" INTEGER NULL, "id" TEXT NOT NULL, "place_id" TEXT NOT NULL REFERENCES places (id), "schedule_name" TEXT NOT NULL, "time_zone_id" TEXT NOT NULL DEFAULT 'UTC', "occurrence_offset_seconds" INTEGER NULL, "schedule_time" TEXT NOT NULL, "move_time" INTEGER NOT NULL, "is_changed" INTEGER NOT NULL DEFAULT 0 CHECK ("is_changed" IN (0, 1)), "is_started" INTEGER NOT NULL DEFAULT 0 CHECK ("is_started" IN (0, 1)), "schedule_spare_time" INTEGER NULL, "schedule_note" TEXT NULL, "lateness_time" INTEGER NOT NULL DEFAULT -1, "done_status" TEXT NOT NULL DEFAULT 'notEnded', "started_at" INTEGER NULL, "finished_at" INTEGER NULL, "preparation_mode" TEXT NULL, "preparation_template_id" TEXT NULL, "preparation_template_name" TEXT NULL, "preparation_template_deleted" INTEGER NOT NULL DEFAULT 0 CHECK ("preparation_template_deleted" IN (0, 1)), "preparation_frozen" INTEGER NOT NULL DEFAULT 0 CHECK ("preparation_frozen" IN (0, 1)), "score_contribution_recorded" INTEGER NOT NULL DEFAULT 0 CHECK ("score_contribution_recorded" IN (0, 1)), "recurring_segment_id" TEXT NULL, "recurring_slot_key" TEXT NULL, "recurring_ordinal" INTEGER NULL, "recurring_overrides" TEXT NOT NULL DEFAULT '', "preparation_definition_id" TEXT NULL, PRIMARY KEY ("id")); +CREATE TABLE "users" ("restore_cleanup_pending" INTEGER NOT NULL DEFAULT 0 CHECK ("restore_cleanup_pending" IN (0, 1)), "reject_legacy_delivery" INTEGER NOT NULL DEFAULT 0 CHECK ("reject_legacy_delivery" IN (0, 1)), "store_incarnation" TEXT NULL, "id" TEXT NOT NULL, "spare_time" INTEGER NOT NULL, "note" TEXT NOT NULL, "is_onboarding_completed" INTEGER NOT NULL DEFAULT 0 CHECK ("is_onboarding_completed" IN (0, 1)), "eligible_outcome_count" INTEGER NOT NULL DEFAULT 0, "on_time_outcome_count" INTEGER NOT NULL DEFAULT 0, "alarms_enabled" INTEGER NOT NULL DEFAULT 1 CHECK ("alarms_enabled" IN (0, 1)), "alarm_offset_minutes" INTEGER NOT NULL DEFAULT 0, "detailed_notification_content" INTEGER NOT NULL DEFAULT 0 CHECK ("detailed_notification_content" IN (0, 1)), "data_revision" INTEGER NOT NULL DEFAULT 0, "last_exported_revision" INTEGER NULL, "last_exported_at" INTEGER NULL, "first_durable_data_at" INTEGER NULL, "last_durable_data_at" INTEGER NULL, PRIMARY KEY ("id")); +CREATE INDEX preparation_schedules_next_preparation_id_idx ON preparation_schedules (next_preparation_id); +CREATE INDEX preparation_schedules_schedule_id_idx ON preparation_schedules (schedule_id); +CREATE INDEX preparation_template_steps_template_id_idx ON preparation_template_steps (template_id); +CREATE INDEX preparation_users_next_preparation_id_idx ON preparation_users (next_preparation_id); +CREATE INDEX preparation_users_user_id_idx ON preparation_users (user_id); +CREATE INDEX schedules_place_id_idx ON schedules (place_id); +CREATE INDEX schedules_schedule_time_idx ON schedules (schedule_time); +CREATE TRIGGER definition_steps_delete AFTER DELETE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=OLD.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=OLD.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=OLD.definition_id); END; +CREATE TRIGGER definition_steps_insert AFTER INSERT ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END; +CREATE TRIGGER definition_steps_update AFTER UPDATE ON preparation_definition_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id=NEW.definition_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id=NEW.definition_id); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT root_segment_id FROM recurring_schedule_segments WHERE preparation_id=NEW.definition_id); END; +CREATE TRIGGER exclusion_delete AFTER DELETE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.segment_id); END; +CREATE TRIGGER exclusion_insert AFTER INSERT ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END; +CREATE TRIGGER exclusion_update AFTER UPDATE ON recurring_schedule_exclusions BEGIN UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.segment_id); END; +CREATE TRIGGER place_version AFTER UPDATE OF place_name ON places WHEN NEW.place_name IS NOT OLD.place_name BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE place_id = NEW.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.place_id=NEW.id); END; +CREATE TRIGGER preparation_default_delete AFTER DELETE ON preparation_users BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END; +CREATE TRIGGER preparation_default_insert AFTER INSERT ON preparation_users BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END; +CREATE TRIGGER preparation_default_update AFTER UPDATE ON preparation_users BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=schedules.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=schedules.preparation_template_id AND schedules.preparation_template_deleted=0); UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_definition_id IS NULL AND NOT EXISTS (SELECT 1 FROM preparation_schedules p WHERE p.schedule_id=s.id) AND NOT EXISTS (SELECT 1 FROM preparation_templates p WHERE p.id=s.preparation_template_id AND s.preparation_template_deleted=0)); END; +CREATE TRIGGER preparation_schedule_delete AFTER DELETE ON preparation_schedules BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = OLD.schedule_id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = OLD.schedule_id)); END; +CREATE TRIGGER preparation_schedule_insert AFTER INSERT ON preparation_schedules BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END; +CREATE TRIGGER preparation_schedule_update AFTER UPDATE ON preparation_schedules BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.schedule_id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = (SELECT recurring_segment_id FROM schedules WHERE id = NEW.schedule_id)); END; +CREATE TRIGGER recurring_schedule_segments_identity AFTER INSERT ON recurring_schedule_segments + WHEN NEW.aggregate_incarnation IS NULL BEGIN + UPDATE recurring_schedule_segments SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END; +CREATE TRIGGER schedules_deleted AFTER DELETE ON schedules BEGIN + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END; +CREATE TRIGGER schedules_identity AFTER INSERT ON schedules + WHEN NEW.aggregate_incarnation IS NULL BEGIN + UPDATE schedules SET aggregate_incarnation = lower(hex(randomblob(16))), aggregate_version = 0 WHERE id = NEW.id; END; +CREATE TRIGGER schedules_version AFTER UPDATE OF place_id,schedule_name,time_zone_id,occurrence_offset_seconds,schedule_time,move_time,is_changed,is_started,schedule_spare_time,schedule_note,lateness_time,done_status,started_at,finished_at,preparation_mode,preparation_template_id,preparation_template_name,preparation_template_deleted,preparation_frozen,score_contribution_recorded,recurring_segment_id,recurring_slot_key,recurring_ordinal,recurring_overrides,preparation_definition_id ON schedules + WHEN NEW.place_id IS NOT OLD.place_id OR NEW.schedule_name IS NOT OLD.schedule_name OR NEW.time_zone_id IS NOT OLD.time_zone_id OR NEW.occurrence_offset_seconds IS NOT OLD.occurrence_offset_seconds OR NEW.schedule_time IS NOT OLD.schedule_time OR NEW.move_time IS NOT OLD.move_time OR NEW.is_changed IS NOT OLD.is_changed OR NEW.is_started IS NOT OLD.is_started OR NEW.schedule_spare_time IS NOT OLD.schedule_spare_time OR NEW.schedule_note IS NOT OLD.schedule_note OR NEW.lateness_time IS NOT OLD.lateness_time OR NEW.done_status IS NOT OLD.done_status OR NEW.started_at IS NOT OLD.started_at OR NEW.finished_at IS NOT OLD.finished_at OR NEW.preparation_mode IS NOT OLD.preparation_mode OR NEW.preparation_template_id IS NOT OLD.preparation_template_id OR NEW.preparation_template_name IS NOT OLD.preparation_template_name OR NEW.preparation_template_deleted IS NOT OLD.preparation_template_deleted OR NEW.preparation_frozen IS NOT OLD.preparation_frozen OR NEW.score_contribution_recorded IS NOT OLD.score_contribution_recorded OR NEW.recurring_segment_id IS NOT OLD.recurring_segment_id OR NEW.recurring_slot_key IS NOT OLD.recurring_slot_key OR NEW.recurring_ordinal IS NOT OLD.recurring_ordinal OR NEW.recurring_overrides IS NOT OLD.recurring_overrides OR NEW.preparation_definition_id IS NOT OLD.preparation_definition_id BEGIN + UPDATE schedules SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = NEW.id; + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = OLD.recurring_segment_id); END; +CREATE TRIGGER segment_root AFTER INSERT ON recurring_schedule_segments WHEN NEW.root_segment_id IS NULL BEGIN UPDATE recurring_schedule_segments SET root_segment_id = COALESCE((SELECT root_segment_id FROM recurring_schedule_segments WHERE series_id=NEW.series_id AND root_segment_id IS NOT NULL LIMIT 1), NEW.id) WHERE id=NEW.id; END; +CREATE TRIGGER segments_version AFTER UPDATE OF rule_json,schedule_json,preparation_id,from_slot,before_slot,preparation_not_before ON recurring_schedule_segments + WHEN NEW.rule_json IS NOT OLD.rule_json OR NEW.schedule_json IS NOT OLD.schedule_json OR NEW.preparation_id IS NOT OLD.preparation_id OR NEW.from_slot IS NOT OLD.from_slot OR NEW.before_slot IS NOT OLD.before_slot OR NEW.preparation_not_before IS NOT OLD.preparation_not_before BEGIN + UPDATE recurring_schedule_segments SET aggregate_version = COALESCE(aggregate_version,0)+1 WHERE id = (SELECT root_segment_id FROM recurring_schedule_segments WHERE id = NEW.id); END; +CREATE TRIGGER template_delete AFTER DELETE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END; +CREATE TRIGGER template_steps_delete AFTER DELETE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.template_id); END; +CREATE TRIGGER template_steps_insert AFTER INSERT ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END; +CREATE TRIGGER template_steps_update AFTER UPDATE ON preparation_template_steps BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=NEW.template_id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=NEW.template_id); END; +CREATE TRIGGER template_update AFTER UPDATE ON preparation_templates BEGIN UPDATE schedules SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE preparation_template_id=OLD.id; UPDATE recurring_schedule_segments SET aggregate_version=COALESCE(aggregate_version,0)+1 WHERE id IN (SELECT r.root_segment_id FROM recurring_schedule_segments r JOIN schedules s ON s.recurring_segment_id=r.id WHERE s.preparation_template_id=OLD.id); END; +CREATE TRIGGER users_identity AFTER INSERT ON users WHEN NEW.store_incarnation IS NULL BEGIN + UPDATE users SET store_incarnation = lower(hex(randomblob(16))) WHERE id = NEW.id; END; +PRAGMA user_version=4; diff --git a/test/helpers/c08_home_query_fixture.dart b/test/helpers/c08_home_query_fixture.dart new file mode 100644 index 00000000..673b9bb9 --- /dev/null +++ b/test/helpers/c08_home_query_fixture.dart @@ -0,0 +1,48 @@ +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +const homeQueryKey = NearestQueryKey(generation: 7, epoch: 2, revision: 5); +const homeQueryProgress = NearestQueryProgress( + visitedCandidates: 1024, + candidateBudget: 200000, + provenSegments: 1, + totalSegments: 3, +); + +NearestQueryAuthority homeQueryAuthority() => NearestQueryAuthority( + key: homeQueryKey, + storeIncarnation: 'fixture-store', + dataRevision: 11, + ruleDataIdentity: 'fixture-rules', + evaluatedAtUtc: DateTime.utc(2029), + verifiedAtUtc: DateTime.utc(2029), +); + +// These are explicit UI projections, not evidence that a query writer ran. +NearestVerifiedSchedule homeVerifiedValue( + ScheduleWithPreparationEntity schedule, { + DateTime? instantUtc, +}) { + assert( + instantUtc != null || + (schedule.timeZoneId == 'UTC' && schedule.occurrenceOffsetSeconds == 0), + ); + return NearestVerifiedSchedule( + schedule: schedule, + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: instantUtc ?? schedule.scheduleTime.toUtc(), + ), + authority: homeQueryAuthority(), + ); +} + +ScheduleState homeReadyState(ScheduleWithPreparationEntity schedule) => + ScheduleState.upcoming(schedule).copyWith( + nearestQuery: NearestQueryReady(value: homeVerifiedValue(schedule)), + ); + +ScheduleState homeEmptyState() => const ScheduleState.notExists().copyWith( + nearestQuery: NearestQueryEmpty(authority: homeQueryAuthority()), +); diff --git a/test/helpers/fake_clock.dart b/test/helpers/fake_clock.dart new file mode 100644 index 00000000..a4bee7e6 --- /dev/null +++ b/test/helpers/fake_clock.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Controls timers while allowing SDK futures created outside this zone to settle. +final class FakeClock { + FakeClock(this._clock, this._outerZone); + final FakeAsync _clock; + final Zone _outerZone; + + Future pump([Duration duration = Duration.zero]) async { + _clock.flushMicrotasks(); + _clock.elapse(duration); + await _outerZone.run(() => Future.delayed(Duration.zero)); + _clock.flushMicrotasks(); + } + + Future complete(Future? operation) async { + if (operation != null) await operation; + } +} + +void fakeClockTest(String description, Future Function(FakeClock) body) { + test(description, () async { + final clock = FakeAsync(); + final outerZone = Zone.current; + var completed = false; + Object? failure; + StackTrace? failureStack; + clock.run((_) { + body(FakeClock(clock, outerZone)).then( + (_) { + completed = true; + }, + onError: (Object error, StackTrace stack) { + completed = true; + failure = error; + failureStack = stack; + }, + ); + }); + // SDK cancellation may return an already-created future whose completion + // uses its original zone. Yield to that zone without advancing fake time. + for (var turn = 0; !completed && turn < 1000; turn++) { + clock.flushMicrotasks(); + await Future.delayed(Duration.zero); + } + clock.flushMicrotasks(); + if (failure != null) Error.throwWithStackTrace(failure!, failureStack!); + expect( + completed, + isTrue, + reason: 'Test did not settle within 1000 event-loop turns.', + ); + expect( + clock.pendingTimers, + isEmpty, + reason: 'Every test must retire its timer owners.', + ); + }); +} diff --git a/test/helpers/nearest_query_fixture.dart b/test/helpers/nearest_query_fixture.dart new file mode 100644 index 00000000..812f2ce9 --- /dev/null +++ b/test/helpers/nearest_query_fixture.dart @@ -0,0 +1,30 @@ +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +export 'package:on_time_front/domain/entities/nearest_schedule_query.dart'; + +NearestScheduleQuery nearestQueryFixture( + ScheduleWithPreparationEntity? schedule, + NearestQueryKey key, +) { + final now = DateTime.now().toUtc(); + final authority = NearestQueryAuthority( + key: key, + storeIncarnation: 'fixture', + dataRevision: 0, + ruleDataIdentity: 'fixture', + evaluatedAtUtc: now, + verifiedAtUtc: now, + ); + if (schedule == null) return NearestQueryEmpty(authority: authority); + final resolution = + schedule.timeResolution ?? + ScheduleTimeResolver.resolve(schedule, nowUtc: now); + return NearestQueryReady( + value: NearestVerifiedSchedule( + schedule: schedule, + resolution: resolution, + authority: authority, + ), + ); +} diff --git a/test/helpers/restore_staging_fixture.dart b/test/helpers/restore_staging_fixture.dart new file mode 100644 index 00000000..054d57cc --- /dev/null +++ b/test/helpers/restore_staging_fixture.dart @@ -0,0 +1,26 @@ +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; + +/// Unit/workflow fixtures exercise materialization/read-back with real SQLite. +/// This explicit injected adapter is not encrypted staging evidence. +Future memoryRestoreStaging() async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final stage = RestoreStaging(db, db.close); + addTearDown(stage.release); + return stage; +} + +Future noPlatformRestoreCleanup() async {} + +Future memoryBackupIngestion(BackupBudget budget) async { + final store = BackupIngestionStore.memoryForTesting(budget); + addTearDown(store.release); + return store; +} + +BackupProcessingOwner testBackupProcessingOwner() => BackupProcessingOwner(); diff --git a/test/helpers/schedule_deletion_fixture.dart b/test/helpers/schedule_deletion_fixture.dart new file mode 100644 index 00000000..521b23e7 --- /dev/null +++ b/test/helpers/schedule_deletion_fixture.dart @@ -0,0 +1,159 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/backup/backup_export_snapshot.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; + +/// Actual FK SQLite, production aggregate writers and portable snapshot codec. +/// Memory staging and the unused timer store are not mobile/encryption proof. +class ScheduleDeletionFixture { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final gate = LocalDataOperationGate(); + DateTime now = DateTime.utc(2029); + late final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + late final aggregate = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + late final schedules = ScheduleRepositoryImpl( + database: db, + recurringScheduleRepository: recurring, + timedPreparationRepository: _UnusedTimerStore(), + now: () => now, + ); + + Future initialize() => db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'retained profile', + isOnboardingCompleted: true, + ), + ); + + Future close() async { + await schedules.dispose(); + await db.close(); + gate.dispose(); + } + + PreparationEntity preparation(String marker) => PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: '$marker-first', + preparationName: '$marker first', + preparationTime: const Duration(minutes: 3), + nextPreparationId: '$marker-last', + ), + PreparationStepEntity( + id: '$marker-last', + preparationName: '$marker last', + preparationTime: const Duration(minutes: 2), + ), + ], + ); + + ScheduleEntity schedule(String id, {PlaceEntity? place, DateTime? at}) => + ScheduleEntity( + id: id, + place: place ?? PlaceEntity(id: 'place-$id', placeName: 'Place $id'), + scheduleName: 'Name $id', + scheduleNote: 'Note $id', + scheduleTime: at ?? DateTime.utc(2030, 1, 2, 10), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + preparationMode: SchedulePreparationMode.custom, + isChanged: true, + isStarted: false, + ); + + Future create(String id, {PlaceEntity? place}) async { + await aggregate.save( + ScheduleFormSubmission( + schedule: schedule(id, place: place), + preparation: preparation(id), + preparationChanged: true, + baseline: await aggregate.newBaseline(), + mutationId: 'create-$id', + ), + editing: false, + ); + } + + Future outcome(String id, ScheduleDoneStatus status) async { + if (status == ScheduleDoneStatus.normalEnd || + status == ScheduleDoneStatus.lateEnd) { + await schedules.finishSchedule( + id, + status == ScheduleDoneStatus.lateEnd ? 7 : 0, + ); + } else if (status == ScheduleDoneStatus.abnormalEnd) { + // Independent historical fixture, not a claim about the finish UI. + await (db.update(db.schedules)..where((t) => t.id.equals(id))).write( + SchedulesCompanion( + doneStatus: Value(status.name), + finishedAt: Value(DateTime.utc(2030, 1, 2, 11)), + ), + ); + } + } + + Future revision() async => + (await db.select(db.users).getSingle()).dataRevision; + + Future<({int eligible, int onTime})> score() async { + final row = await db.select(db.users).getSingle(); + return (eligible: row.eligibleOutcomeCount, onTime: row.onTimeOutcomeCount); + } + + Future> exportBytes() async { + final snapshot = await BackupExportSnapshot.create( + db, + cutoff: now, + sourceAppVersion: '1.0+7', + sourcePlatform: 'android', + budget: BackupBudget(), + stagingFactory: () async { + final stage = AppDatabase.forTesting(NativeDatabase.memory()); + return RestoreStaging(stage, stage.close); + }, + ); + try { + return await snapshot.plaintext().expand((part) => part).toList(); + } finally { + await snapshot.release(); + } + } +} + +class _UnusedTimerStore implements TimedPreparationRepository { + @override + Future clearTimedPreparation(String scheduleId) async {} + @override + Future getTimedPreparationSnapshot( + String scheduleId, + ) async => null; + @override + Future saveTimedPreparationSnapshot( + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ) async {} +} diff --git a/test/helpers/schedule_deletion_workflow_fixture.dart b/test/helpers/schedule_deletion_workflow_fixture.dart new file mode 100644 index 00000000..5f035788 --- /dev/null +++ b/test/helpers/schedule_deletion_workflow_fixture.dart @@ -0,0 +1,219 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; +import 'package:on_time_front/domain/repositories/early_start_session_repository.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import 'noop_alarm_reconciliation.dart'; + +// Real aggregate/SQLite/session/orchestration. Only storage and OS ports are +// controllable, and durable tests supply an actual FileAlarmJournalStore. +class DeletionWorkflowFixture { + DeletionWorkflowFixture({ + AlarmJournalStore? journalStore, + AppDatabase? database, + }) : db = database ?? AppDatabase.forTesting(NativeDatabase.memory()) { + owner = AlarmOperationCoordinator( + gate, + journal: AlarmOwnershipJournal(journalStore ?? MemoryAlarmJournalStore()), + ); + sessions = makeSessions(owner); + deletion = makeDeletion(owner, sessions); + } + final AppDatabase db; + final gate = LocalDataOperationGate(); + late final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + late final aggregate = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + final now = DateTime.utc(2029); + final registry = DeletionRegistry(); + final scheduler = DeletionNative(); + final fallback = DeletionFallback(); + final timed = DeletionTimed(); + final early = DeletionEarly(); + final reconciliation = NoopAlarmReconciliation(); + late final AlarmOperationCoordinator owner; + late final SchedulePreparationSessionUseCase sessions; + late final DeleteScheduleUseCase deletion; + SchedulePreparationSessionUseCase makeSessions( + AlarmOperationCoordinator operations, + ) => SchedulePreparationSessionUseCase( + _UnusedSchedules(), + _UnusedPreparation(), + timed, + early, + CancelScheduleAlarmUseCase( + registry, + scheduler, + fallback, + operations: operations, + ), + reconciliation, + operations: operations, + ); + DeleteScheduleUseCase makeDeletion( + AlarmOperationCoordinator operations, + SchedulePreparationSessionUseCase session, + ) => DeleteScheduleUseCase( + aggregate, + registry, + scheduler, + fallback, + session, + reconciliation, + operations: operations, + ); + Future open() async => db.userDao.putUser( + const UserEntity(id: 'local-profile', spareTime: Duration.zero, note: ''), + ); + ScheduleEntity schedule([String id = 'one']) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'private place'), + scheduleName: 'private title', + scheduleTime: DateTime.utc(2030, 1, 1, 10), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: 'private note', + ); + Future create([String id = 'one']) async { + final value = schedule(id); + await db.scheduleDao.createSchedule(value.toScheduleWithPlaceRow()); + await db.preparationScheduleDao.createPreparationSchedule( + const PreparationEntity(preparationStepList: []), + id, + ); + return value; + } + + Future revision() async => + (await db.select(db.users).getSingle()).dataRevision; + ScheduledAlarmRecord record(String id, int notification) => + ScheduledAlarmRecord( + scheduleId: id, + provider: AlarmProvider.localNotification, + fallbackNotificationId: notification, + alarmTime: DateTime.utc(2030), + preparationStartTime: DateTime.utc(2030), + scheduleFingerprint: 'private fingerprint', + scheduleTitle: 'private title', + payload: const {'note': 'private payload'}, + ); + Future close() async { + sessions.dispose(); + owner.dispose(); + gate.dispose(); + await db.close(); + } +} + +class _UnusedSchedules extends Fake implements ScheduleRepository {} + +class _UnusedPreparation extends Fake implements PreparationRepository {} + +class DeletionRegistry extends Fake implements AlarmRegistryRepository { + List records = []; + int writes = 0; + @override + Future> loadAll() async => List.of(records); + @override + Future replaceAll(List value) async { + writes++; + records = List.of(value); + } +} + +class DeletionNative extends Fake implements AlarmSchedulerService { + final cancelled = []; + Future Function(ScheduledAlarmRecord)? onCancel; + @override + Future cancelNativeAlarm(ScheduledAlarmRecord value) async { + cancelled.add(value); + await onCancel?.call(value); + } +} + +class DeletionFallback extends Fake + implements FallbackAlarmNotificationService { + final cancelled = []; + Future Function(ScheduledAlarmRecord)? onCancel; + @override + Future cancelFallbackAlarm(ScheduledAlarmRecord value) async { + cancelled.add(value); + await onCancel?.call(value); + } +} + +class DeletionTimed extends Fake implements TimedPreparationRepository { + final snapshots = {}; + final cleared = []; + Future Function(String)? onClear; + @override + Future getTimedPreparationSnapshot( + String id, + ) async => snapshots[id]; + @override + Future saveTimedPreparationSnapshot( + String id, + TimedPreparationSnapshotEntity value, + ) async { + snapshots[id] = value; + } + + @override + Future clearTimedPreparation(String id) async { + cleared.add(id); + snapshots.remove(id); + await onClear?.call(id); + } +} + +class DeletionEarly extends Fake implements EarlyStartSessionRepository { + final values = {}; + final cleared = []; + @override + Future getSession(String id) async => values[id]; + @override + Future markStarted({ + required String scheduleId, + required DateTime startedAt, + }) async { + values[scheduleId] = EarlyStartSessionEntity( + scheduleId: scheduleId, + startedAt: startedAt, + ); + } + + @override + Future clear(String id) async { + cleared.add(id); + values.remove(id); + } +} diff --git a/test/helpers/u02_save_fixture.dart b/test/helpers/u02_save_fixture.dart new file mode 100644 index 00000000..d9047501 --- /dev/null +++ b/test/helpers/u02_save_fixture.dart @@ -0,0 +1,230 @@ +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +// Production Bloc -> use cases -> workflow -> aggregate -> actual SQLite. +// Controls platform time/id, OS effects, and optional post-commit response loss. +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/schedule_aggregate_repository.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/domain/use-cases/schedule_save_workflow.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; + +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/use-cases/recurring_schedules_use_case.dart'; + +class _UnusedSchedules extends Fake implements ScheduleRepository {} + +class _UnusedLoad extends Fake implements LoadPreparationByScheduleIdUseCase {} + +class _UnusedPreparation extends Fake + implements GetPreparationByScheduleIdUseCase {} + +class _UnusedDefault extends Fake implements GetDefaultPreparationUseCase {} + +class _UnusedSchedule extends Fake implements GetScheduleByIdUseCase {} + +class U02AlarmEffects extends Fake + implements ScheduleMutationAlarmEffectsCoordinator { + int calls = 0; + bool succeeds = true; + @override + Future afterCommit() async { + calls++; + return succeeds; + } +} + +class U02SaveFixture { + U02SaveFixture({this.loseFirstSaveResponseOnce = false}); + + bool loseFirstSaveResponseOnce; + final gate = LocalDataOperationGate(); + late RecurringScheduleRepositoryImpl recurring; + late AppDatabase db; + late ScheduleAggregateRepositoryImpl aggregate; + late ScheduleFormBloc bloc; + final effects = U02AlarmEffects(); + DateTime now = DateTime.utc(2026, 10, 1); + String device = 'America/New_York'; + int ids = 0; + + Future open() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + aggregate = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: gate, + now: () => now, + ); + await db.userDao.putUser( + const UserEntity(id: localProfileId, spareTime: Duration.zero, note: ''), + ); + final load = LoadScheduleFormDraftUseCase.withOverrides( + _UnusedLoad(), + _UnusedPreparation(), + _UnusedDefault(), + _UnusedSchedule(), + now: () => now, + newId: () => 'synthetic-${++ids}', + timeZoneId: () async => device, + aggregate: aggregate, + ); + final workflow = ScheduleSaveWorkflow( + _ResponseLossAggregate(aggregate, () { + final loseResponse = loseFirstSaveResponseOnce; + loseFirstSaveResponseOnce = false; + return loseResponse; + }), + effects, + ); + bloc = ScheduleFormBloc( + load, + CreateScheduleFormSubmissionUseCase(workflow), + UpdateScheduleFormSubmissionUseCase(workflow), + aggregate: aggregate, + recurringSchedules: RecurringSchedulesUseCase( + recurring, + _UnusedSchedules(), + effects, + _UnusedDeletion(), + ), + now: () => now, + ); + } + + Future send( + ScheduleFormEvent event, + bool Function(ScheduleFormState) accepts, + ) async { + final result = bloc.stream + .firstWhere(accepts) + .timeout(const Duration(seconds: 10)); + bloc.add(event); + await result; + // Let the event handler's finally retire its pending owner before next input. + await Future.delayed(Duration.zero); + } + + Future draft(int offset) async { + await send( + const ScheduleFormCreateRequested(), + (s) => s.status == ScheduleFormStatus.success, + ); + await send( + const ScheduleFormScheduleNameChanged( + scheduleName: 'Synthetic fold appointment', + ), + (s) => s.scheduleName != null, + ); + await send( + const ScheduleFormPlaceNameChanged(placeName: 'Synthetic place'), + (s) => s.placeName != null, + ); + await send( + const ScheduleFormMoveTimeChanged(moveTime: Duration.zero), + (s) => s.moveTime != null, + ); + await send( + ScheduleFormPreparationChanged( + preparation: PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'fixture-step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 10), + ), + ], + ), + ), + (s) => s.totalPreparationTime == const Duration(minutes: 10), + ); + final civil = DateTime.utc(2026, 11, 1, 1, 30, 1, 123, 456); + await send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'America/New_York', + timeZoneExplicitlySelected: true, + occurrenceOffsetSeconds: offset, + ), + (s) => s.scheduleTime == civil && s.occurrenceOffsetSeconds == offset, + ); + } + + Future revision() async => + (await db.select(db.users).getSingle()).dataRevision; + Future close() async { + await bloc.close(); + await db.close(); + } +} + +// The real transaction completes before this optional transport-style failure. +// A retry still reaches the production writer and its persisted receipt lookup. +class _ResponseLossAggregate implements ScheduleAggregateRepository { + _ResponseLossAggregate(this.delegate, this.consumeResponseLoss); + + final ScheduleAggregateRepository delegate; + final bool Function() consumeResponseLoss; + + @override + Future readForDeletion( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) => delegate.readForDeletion(id, scope: scope); + + @override + Future delete(ScheduleDeletionIntent intent) => + delegate.delete(intent); + + @override + Future isDeletionCurrent(ScheduleDeletionCommit commit) => + delegate.isDeletionCurrent(commit); + + @override + Future newBaseline() => delegate.newBaseline(); + + @override + Future<({ScheduleEditBaseline baseline, PreparationEntity preparation})> + newDraft() => delegate.newDraft(); + + @override + Future readForEdit(String id) => + delegate.readForEdit(id); + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => delegate.isCurrent(receipt); + + @override + Future save( + ScheduleFormSubmission submission, { + required bool editing, + }) async { + final receipt = await delegate.save(submission, editing: editing); + if (consumeResponseLoss()) { + throw StateError('Synthetic response loss after committed save'); + } + return receipt; + } +} + +class _UnusedDeletion extends Fake implements DeleteScheduleUseCase {} diff --git a/test/native/AlarmLaunchPayloadTest.kt b/test/native/AlarmLaunchPayloadTest.kt index 07a6dd2b..058907ca 100644 --- a/test/native/AlarmLaunchPayloadTest.kt +++ b/test/native/AlarmLaunchPayloadTest.kt @@ -13,7 +13,7 @@ fun main() { val clean = requireNotNull(AlarmLaunchPayload.sanitize(old)) check(clean == mapOf( "scheduleId" to "fixture-schedule", "type" to "schedule_alarm", - "alarmLaunchPayloadVersion" to "9", "promptVariant" to "alarm", + "alarmLaunchPayloadVersion" to "10", "promptVariant" to "alarm", )) check(!clean.toString().contains(privateMarker)) check(AlarmLaunchPayload.sanitize(clean) == clean) @@ -21,6 +21,14 @@ fun main() { check(AlarmLaunchPayload.sanitize(mapOf("scheduleId" to id)) == null) } check(AlarmLaunchPayload.sanitize(mapOf("scheduleId" to "한글-🙂")) != null) + val identity = "11111111-1111-1111-1111-111111111111" + val issued = old + mapOf("storeIncarnation" to identity) + check(AlarmLaunchPayload.sanitize(issued)?.get("storeIncarnation") == identity) + check(AlarmLaunchPayload.sanitize(AlarmLaunchPayload.deliveryExtras(issued))?.get("storeIncarnation") == identity) + check(!clean.containsKey("storeIncarnation")) + for (bad in listOf(42, "", "private-value", "g".repeat(36), null)) { + check(AlarmLaunchPayload.sanitize(old + mapOf("storeIncarnation" to bad)) == null) + } val delivery = AlarmLaunchPayload.deliveryExtras(old) check(delivery["title"] == privateMarker && delivery["body"] == privateMarker) check(delivery["nativeAlarmId"] == "42" && delivery["alarmTime"] == "1234") diff --git a/test/native/AlarmLaunchPayloadTest.swift b/test/native/AlarmLaunchPayloadTest.swift index 4e502689..425ee7df 100644 --- a/test/native/AlarmLaunchPayloadTest.swift +++ b/test/native/AlarmLaunchPayloadTest.swift @@ -22,13 +22,22 @@ struct AlarmLaunchPayloadTest { "alarmLaunchPayloadVersion": "8", "type": "start-command" ] let expected = ["scheduleId": "fixture-schedule", "type": "schedule_alarm", - "alarmLaunchPayloadVersion": "9", "promptVariant": "alarm"] + "alarmLaunchPayloadVersion": "10", "promptVariant": "alarm"] precondition(AlarmLaunchPayload.sanitize(old) == expected) precondition(AlarmLaunchPayload.sanitize(expected) == expected) for id: Any in [123, ["id"], "", " ", "x\ny", "x\u{7f}y", String(repeating: "x", count: 513)] { precondition(AlarmLaunchPayload.sanitize(["scheduleId": id]) == nil) } precondition(AlarmLaunchPayload.sanitize(["scheduleId": "한글-🙂"]) != nil) + let identity = "11111111-1111-1111-1111-111111111111" + var issued = old + issued["storeIncarnation"] = identity + precondition(AlarmLaunchPayload.sanitize(issued)?["storeIncarnation"] == identity) + precondition(AlarmLaunchPayload.sanitize(expected)?["storeIncarnation"] == nil) + for bad: Any in [42, "", "private-value", String(repeating: "g", count: 36)] { + issued["storeIncarnation"] = bad + precondition(AlarmLaunchPayload.sanitize(issued) == nil) + } let suite = "ontime-a11-test-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } diff --git a/test/presentation/alarm/screens/preparation_flow_widget_test.dart b/test/presentation/alarm/screens/preparation_flow_widget_test.dart index c5fb19af..409ae79e 100644 --- a/test/presentation/alarm/screens/preparation_flow_widget_test.dart +++ b/test/presentation/alarm/screens/preparation_flow_widget_test.dart @@ -1,3 +1,7 @@ +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import '../../../helpers/nearest_query_fixture.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'dart:async'; import 'dart:convert'; @@ -35,11 +39,28 @@ import 'package:on_time_front/presentation/shared/theme/theme.dart'; class StubGetNearestUpcomingScheduleUseCase implements GetNearestUpcomingScheduleUseCase { - StubGetNearestUpcomingScheduleUseCase(this.streamFactory); + StubGetNearestUpcomingScheduleUseCase( + this.streamFactory, { + this.includeInFutureQuery = true, + }); + final bool includeInFutureQuery; final Stream Function() streamFactory; @override - Stream call() => streamFactory(); + Stream call({required NearestQueryKey key}) => + streamFactory().map((value) { + _last = value; + return nearestQueryFixture(includeInFutureQuery ? value : null, key); + }); + ScheduleWithPreparationEntity? _last; + @override + Future readActive() async => + _last != null && + _last!.isStarted && + _last!.startedAt != null && + _last!.preparationFrozen + ? _last + : null; } class SpyNavigationService extends NavigationService { @@ -171,6 +192,20 @@ EarlyStartUseCaseBundle createEarlyStartUseCaseBundle() { class TestSchedulePreparationSessionUseCase implements SchedulePreparationSessionUseCase { @override + Future assertDeletionAllowed( + ScheduleEditSnapshot snapshot, { + required AlarmOperationLease lease, + }) => + throw UnimplementedError('Deletion is outside this preparation fixture'); + @override + Future clearDeletedStateUnderOwner( + String scheduleId, { + required AlarmOperationLease lease, + required Future Function() isCurrent, + }) => + throw UnimplementedError('Deletion is outside this preparation fixture'); + + @override void dispose() {} TestSchedulePreparationSessionUseCase({ @@ -214,9 +249,15 @@ class TestSchedulePreparationSessionUseCase } @override - Future startSchedulePreparation(String scheduleId) async { - if (!_startedScheduleIds.add(scheduleId)) return; + Future startSchedulePreparation( + String scheduleId, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) async { + if (!_startedScheduleIds.add(scheduleId)) return DateTime.now().toUtc(); await startScheduleUseCase(scheduleId); + + return DateTime.now().toUtc(); } @override @@ -271,6 +312,10 @@ class TestSchedulePreparationSessionUseCase return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, restoredPreparation, + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now().toUtc(), + ), ); } @@ -327,21 +372,48 @@ ScheduleWithPreparationEntity buildSchedule({ required List steps, Duration moveTime = const Duration(minutes: 20), Duration scheduleSpareTime = const Duration(minutes: 10), + DateTime? startedAt, }) { return ScheduleWithPreparationEntity( id: id, place: PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: scheduleTime, + // Caller supplies an instant; this fixture stores explicit UTC wall fields. + scheduleTime: scheduleTime.toUtc(), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, moveTime: moveTime, isChanged: false, - isStarted: false, + isStarted: startedAt != null, + startedAt: startedAt?.toUtc(), + preparationFrozen: startedAt != null, scheduleSpareTime: scheduleSpareTime, scheduleNote: '', preparation: PreparationWithTimeEntity(preparationStepList: steps), ); } +// An active UI fixture contains both a durable start fact and its matching +// runtime projection. Merely receiving an upcoming appointment cannot start it. +Map activeSnapshot( + ScheduleWithPreparationEntity schedule, + DateTime savedAt, +) { + assert( + schedule.isStarted && + schedule.startedAt != null && + schedule.preparationFrozen, + ); + return { + schedule.id: TimedPreparationSnapshotEntity( + preparation: schedule.preparation, + savedAt: savedAt, + startedAt: schedule.startedAt, + scheduleFingerprint: schedule.cacheFingerprint, + ), + }; +} + Future pumpWithRouter( WidgetTester tester, { required ScheduleBloc bloc, @@ -1124,6 +1196,7 @@ void main() { now = DateTime.now(); final schedule = buildSchedule( + startedAt: now, id: 's-active-close', scheduleTime: now.add(const Duration(minutes: 35)), steps: const [ @@ -1153,7 +1226,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1161,17 +1236,20 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound( - tester, - find.byKey(const Key('alarm_close_button')), - ); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound( + tester, + find.byKey(const Key('alarm_close_button')), + ); - expect(find.byKey(const Key('alarm_close_button')), findsOneWidget); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); + expect(find.byKey(const Key('alarm_close_button')), findsOneWidget); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1280,6 +1358,7 @@ void main() { now = DateTime.now(); final schedule = buildSchedule( + startedAt: now, id: 's-active-leave', scheduleTime: now.add(const Duration(minutes: 35)), steps: const [ @@ -1309,7 +1388,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1317,17 +1398,20 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await tapAndPump(tester, find.byKey(const Key('alarm_close_button'))); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await tapAndPump(tester, find.byKey(const Key('alarm_close_button'))); + await pumpUntilRouteText(tester, 'HOME'); - await pumpUntilRouteText(tester, 'HOME'); - - expect(find.byType(TwoActionDialog), findsNothing); - expect(find.text('HOME'), findsOneWidget); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); + expect(find.byType(TwoActionDialog), findsNothing); + expect(find.text('HOME'), findsOneWidget); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1339,6 +1423,7 @@ void main() { now = DateTime.now(); final schedule = buildSchedule( + startedAt: now, id: 's3', scheduleTime: now.add(const Duration(minutes: 35)), steps: const [ @@ -1377,7 +1462,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1385,16 +1472,19 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(ElevatedButton)); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(ElevatedButton)); - await tapAndPump(tester, find.byType(ElevatedButton).first); - await pumpUntilFound(tester, find.textContaining('EARLYLATE:false')); + await tapAndPump(tester, find.byType(ElevatedButton).first); + await pumpUntilFound(tester, find.textContaining('EARLYLATE:false')); - expect(finishUseCase.calls.single.$2, 0); - expect(find.textContaining('EARLYLATE:false'), findsOneWidget); + expect(finishUseCase.calls.single.$2, 0); + expect(find.textContaining('EARLYLATE:false'), findsOneWidget); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1406,6 +1496,8 @@ void main() { now = DateTime.now(); final schedule = buildSchedule( + // This existing run began at its planned boundary 15 minutes ago. + startedAt: now.subtract(const Duration(minutes: 15)), id: 's4', scheduleTime: now.add(const Duration(minutes: 25)), steps: const [ @@ -1446,7 +1538,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, schedule.startedAt!), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1454,27 +1548,31 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - // This case auto-completes the only step immediately, so finish via dialog. - await pumpUntilFound(tester, find.byType(TwoActionDialog)); - await tapAndPump(tester, find.byType(ModalWideButton).last); - await pumpUntilFound(tester, find.textContaining('EARLYLATE:true')); - - expect(finishUseCase.calls.single.$2, greaterThan(0)); - expect(find.textContaining('EARLYLATE:true'), findsOneWidget); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + // This case auto-completes the only step immediately, so finish via dialog. + await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await tapAndPump(tester, find.byType(ModalWideButton).last); + await pumpUntilFound(tester, find.textContaining('EARLYLATE:true')); + + expect(finishUseCase.calls.single.$2, greaterThan(0)); + expect(find.textContaining('EARLYLATE:true'), findsOneWidget); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); testWidgets( - 'completion dialog continue shows live leave countdown for ongoing flow', + 'completion dialog continue shows live leave countdown for restored durable flow', (tester) async { await setLargeTestViewport(tester); now = DateTime(2026, 3, 20, 9, 25); final schedule = buildSchedule( + startedAt: now.subtract(const Duration(minutes: 10)), id: 's5', scheduleTime: DateTime(2026, 3, 20, 10, 0), steps: const [ @@ -1510,7 +1608,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1518,54 +1618,57 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); - - await tapAndPump(tester, find.byType(ModalWideButton).first); - - final continuingTheme = tester - .widget(find.byKey(const ValueKey('alarm_screen_theme'))) - .data; - final continuingScaffold = tester.widget( - find.descendant( - of: find.byKey(const ValueKey('alarm_screen_theme')), - matching: find.byType(Scaffold), - ), - ); - - expect( - continuingTheme.colorScheme.primary.toARGB32(), - const Color(0xFF5C79FB).toARGB32(), - ); - expect( - continuingTheme.colorScheme.primaryContainer.toARGB32(), - const Color(0xFFDCE3FF).toARGB32(), - ); - expect( - continuingTheme.colorScheme.onPrimaryContainer.toARGB32(), - const Color(0xFF212F6F).toARGB32(), - ); - expect( - continuingScaffold.backgroundColor!.toARGB32(), - const Color(0xFF5C79FB).toARGB32(), - ); - expect(find.text('EARLYLATE'), findsNothing); - expect(find.text('Ready to go'), findsOneWidget); - expect(find.text('5분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('05 : 00'), findsOneWidget); - expect(finishUseCase.calls, isEmpty); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - now = now.add(const Duration(minutes: 1)); - await tester.pump(const Duration(seconds: 1)); + await tapAndPump(tester, find.byType(ModalWideButton).first); - expect(find.text('4분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('Ready to go'), findsOneWidget); - expect(find.text('04 : 00'), findsOneWidget); + final continuingTheme = tester + .widget(find.byKey(const ValueKey('alarm_screen_theme'))) + .data; + final continuingScaffold = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('alarm_screen_theme')), + matching: find.byType(Scaffold), + ), + ); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); + expect( + continuingTheme.colorScheme.primary.toARGB32(), + const Color(0xFF5C79FB).toARGB32(), + ); + expect( + continuingTheme.colorScheme.primaryContainer.toARGB32(), + const Color(0xFFDCE3FF).toARGB32(), + ); + expect( + continuingTheme.colorScheme.onPrimaryContainer.toARGB32(), + const Color(0xFF212F6F).toARGB32(), + ); + expect( + continuingScaffold.backgroundColor!.toARGB32(), + const Color(0xFF5C79FB).toARGB32(), + ); + expect(find.text('EARLYLATE'), findsNothing); + expect(find.text('Ready to go'), findsOneWidget); + expect(find.text('5분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('05 : 00'), findsOneWidget); + expect(finishUseCase.calls, isEmpty); + + now = now.add(const Duration(minutes: 1)); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('4분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('Ready to go'), findsOneWidget); + expect(find.text('04 : 00'), findsOneWidget); + + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1577,6 +1680,7 @@ void main() { now = DateTime(2026, 3, 20, 8, 30); final schedule = buildSchedule( + startedAt: now.subtract(const Duration(minutes: 10)), id: 's5-early', scheduleTime: DateTime(2026, 3, 20, 10, 0), steps: const [ @@ -1609,7 +1713,7 @@ void main() { final earlyBundle = createEarlyStartUseCaseBundle(); await earlyBundle.markUseCase( scheduleId: schedule.id, - startedAt: now.subtract(const Duration(minutes: 1)), + startedAt: schedule.startedAt!, ); final alarmBloc = ScheduleBloc.test( @@ -1617,7 +1721,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1625,24 +1731,27 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await tapAndPump(tester, find.byType(ModalWideButton).first); - await tapAndPump(tester, find.byType(ModalWideButton).first); + expect(find.text('1시간 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('01 : 00 : 00'), findsOneWidget); - expect(find.text('1시간 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('01 : 00 : 00'), findsOneWidget); + now = now.add(const Duration(minutes: 5)); + await tester.pump(const Duration(seconds: 1)); - now = now.add(const Duration(minutes: 5)); - await tester.pump(const Duration(seconds: 1)); + expect(find.text('55분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('55 : 00'), findsOneWidget); - expect(find.text('55분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('55 : 00'), findsOneWidget); - - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1654,6 +1763,7 @@ void main() { now = DateTime(2026, 3, 20, 9, 29); final schedule = buildSchedule( + startedAt: now.subtract(const Duration(minutes: 10)), id: 's5-late', scheduleTime: DateTime(2026, 3, 20, 10, 0), steps: const [ @@ -1689,7 +1799,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1697,73 +1809,76 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await tapAndPump(tester, find.byType(ModalWideButton).first); - await tapAndPump(tester, find.byType(ModalWideButton).first); + expect(find.text('1분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('01 : 00'), findsOneWidget); - expect(find.text('1분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('01 : 00'), findsOneWidget); + now = now.add(const Duration(minutes: 2)); + await tester.pump(const Duration(seconds: 1)); - now = now.add(const Duration(minutes: 2)); - await tester.pump(const Duration(seconds: 1)); - - final lateTheme = tester - .widget(find.byKey(const ValueKey('alarm_screen_theme'))) - .data; - final lateScaffold = tester.widget( - find.descendant( - of: find.byKey(const ValueKey('alarm_screen_theme')), - matching: find.byType(Scaffold), - ), - ); + final lateTheme = tester + .widget(find.byKey(const ValueKey('alarm_screen_theme'))) + .data; + final lateScaffold = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('alarm_screen_theme')), + matching: find.byType(Scaffold), + ), + ); - expect( - lateTheme.colorScheme.primary.toARGB32(), - const Color(0xFFFF6953).toARGB32(), - ); - expect( - lateTheme.colorScheme.primaryContainer.toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), - ); - expect( - lateTheme.colorScheme.onPrimaryContainer.toARGB32(), - const Color(0xFFFF6953).toARGB32(), - ); - expect( - lateScaffold.backgroundColor!.toARGB32(), - const Color(0xFFFF6953).toARGB32(), - ); - expect( - tester - .widget(find.byType(AlarmGraphAnimator)) - .progress, - 0.0, - ); - expect( - tester - .widget(find.byType(AlarmGraphAnimator)) - .backgroundColor - .toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), - ); - expect( - tester - .widget(find.byType(AlarmGraphAnimator)) - .progressColor - .toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), - ); - expect(find.text('준비시간을 1분 초과했어요'), findsOneWidget); - expect(find.text('지각이에요'), findsOneWidget); - expect(find.text('Ready to go'), findsNothing); - expect(find.text('01 : 00'), findsOneWidget); - expect(find.text('Prep'), findsOneWidget); - - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); + expect( + lateTheme.colorScheme.primary.toARGB32(), + const Color(0xFFFF6953).toARGB32(), + ); + expect( + lateTheme.colorScheme.primaryContainer.toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), + ); + expect( + lateTheme.colorScheme.onPrimaryContainer.toARGB32(), + const Color(0xFFFF6953).toARGB32(), + ); + expect( + lateScaffold.backgroundColor!.toARGB32(), + const Color(0xFFFF6953).toARGB32(), + ); + expect( + tester + .widget(find.byType(AlarmGraphAnimator)) + .progress, + 0.0, + ); + expect( + tester + .widget(find.byType(AlarmGraphAnimator)) + .backgroundColor + .toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), + ); + expect( + tester + .widget(find.byType(AlarmGraphAnimator)) + .progressColor + .toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), + ); + expect(find.text('준비시간을 1분 초과했어요'), findsOneWidget); + expect(find.text('지각이에요'), findsOneWidget); + expect(find.text('Ready to go'), findsNothing); + expect(find.text('01 : 00'), findsOneWidget); + expect(find.text('Prep'), findsOneWidget); + + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1775,6 +1890,7 @@ void main() { now = DateTime.now(); final schedule = buildSchedule( + startedAt: now.subtract(const Duration(minutes: 10)), id: 's6', scheduleTime: now.add(const Duration(minutes: 35)), steps: const [ @@ -1810,7 +1926,9 @@ void main() { navigationService, createSessionUseCase( saveUseCase: NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase( + activeSnapshot(schedule, now), + ), clearTimedUseCase: NoopClearTimedPreparationUseCase(), startUseCase: startUseCase, finishUseCase: finishUseCase, @@ -1818,16 +1936,19 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await tapAndPump(tester, find.byType(ModalWideButton).last); + await pumpUntilRouteText(tester, 'EARLYLATE'); - await tapAndPump(tester, find.byType(ModalWideButton).last); - await pumpUntilRouteText(tester, 'EARLYLATE'); - - expect(find.text('EARLYLATE'), findsOneWidget); - expect(finishUseCase.calls.length, 1); + expect(find.text('EARLYLATE'), findsOneWidget); + expect(finishUseCase.calls.length, 1); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1866,14 +1987,17 @@ void main() { ), nowProvider: () => now, )..emit(const ScheduleState.notExists()); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilRouteText(tester, 'HOME'); - - expect(find.text('HOME'), findsOneWidget); - expect(find.text('EARLYLATE'), findsNothing); - expect(finishUseCase.calls, isEmpty); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilRouteText(tester, 'HOME'); + + expect(find.text('HOME'), findsOneWidget); + expect(find.text('EARLYLATE'), findsNothing); + expect(finishUseCase.calls, isEmpty); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1912,14 +2036,17 @@ void main() { ), nowProvider: () => now, )..emit(const ScheduleState.notExists()); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilRouteText(tester, 'HOME'); - - expect(find.text('HOME'), findsOneWidget); - expect(find.text('EARLYLATE'), findsNothing); - expect(finishUseCase.calls, isEmpty); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilRouteText(tester, 'HOME'); + + expect(find.text('HOME'), findsOneWidget); + expect(find.text('EARLYLATE'), findsNothing); + expect(finishUseCase.calls, isEmpty); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, timeout: const Timeout(Duration(seconds: 15)), ); @@ -1961,6 +2088,7 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase( () => Stream.value(staleEndedSchedule), + includeInFutureQuery: false, ), navigationService, createSessionUseCase( @@ -1973,13 +2101,16 @@ void main() { ), nowProvider: () => now, ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilRouteText(tester, 'HOME'); + try { + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilRouteText(tester, 'HOME'); - expect(find.text('HOME'), findsOneWidget); - expect(finishUseCase.calls, isEmpty); + expect(find.text('HOME'), findsOneWidget); + expect(finishUseCase.calls, isEmpty); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(alarmBloc.close); + } }, ); }); diff --git a/test/presentation/app/bloc/schedule/nearest_active_run_lifecycle_test.dart b/test/presentation/app/bloc/schedule/nearest_active_run_lifecycle_test.dart new file mode 100644 index 00000000..c057fc7f --- /dev/null +++ b/test/presentation/app/bloc/schedule/nearest_active_run_lifecycle_test.dart @@ -0,0 +1,434 @@ +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import '../../../../helpers/nearest_query_fixture.dart'; +import 'dart:async'; + +import '../../../../helpers/fake_clock.dart'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +class _Nearest implements GetNearestUpcomingScheduleUseCase { + _Nearest(this.value); + ScheduleWithPreparationEntity? value; + bool initialActiveRead = false; + bool terminalLimited = false; + @override + Future readActive() async => + (initialActiveRead || sources.isNotEmpty) && + value != null && + value!.isStarted && + value!.startedAt != null && + value!.preparationFrozen && + value!.doneStatus == ScheduleDoneStatus.notEnded + ? value + : null; + final sources = >[]; + @override + Stream call({required NearestQueryKey key}) { + final source = StreamController(); + sources.add(source); + source.add(value); + return source.stream.map((received) { + value = received; + if (terminalLimited) { + return NearestQueryLimited( + key: key, + reason: NearestQueryLimitReason.searchBudgetExhausted, + progress: const NearestQueryProgress( + visitedCandidates: 200000, + candidateBudget: 200000, + provenSegments: 0, + totalSegments: 1, + ), + canContinue: false, + canRetry: false, + ); + } + return nearestQueryFixture(received, key); + }); + } +} + +class _Navigation extends NavigationService { + final routes = []; + @override + void push(String routeName, {Object? extra}) => routes.add(routeName); +} + +class _Session extends Fake implements SchedulePreparationSessionUseCase { + bool hasValidatedRuntime = true; + Duration restoredStartDifference = Duration.zero; + Completer? pendingRestore; + final starts = []; + final clears = []; + @override + Future getEarlyStartSession(String id) async => + null; + @override + Future startSchedulePreparation( + String id, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) async { + starts.add(id); + + return DateTime.now().toUtc(); + } + + @override + Future clearPersistedState(String id) async { + clears.add(id); + } + + @override + Future restoreTimedPreparationIfValid( + ScheduleWithPreparationEntity schedule, { + required DateTime now, + RestoredSessionCallback? onRestoredSession, + void Function()? onInvalidated, + }) async { + final pending = pendingRestore; + pendingRestore = null; + if (pending != null) await pending.future; + if (hasValidatedRuntime) { + onRestoredSession?.call( + startedAt: schedule.startedAt?.add(restoredStartDifference), + actionEvents: const [], + ); + } + return schedule; + } + + @override + Future saveTimedPreparationSnapshot( + ScheduleWithPreparationEntity schedule, { + DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], + bool persist = true, + }) async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final now = DateTime.utc(2030, 1, 2, 0, 5); + ScheduleWithPreparationEntity active() => ScheduleWithPreparationEntity( + id: 'active', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Yesterday appointment', + scheduleTime: DateTime.utc(2030, 1, 1, 23, 55), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: true, + startedAt: DateTime.utc(2030, 1, 1, 23, 35), + preparationFrozen: true, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prepare', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 20), + nextPreparationId: null, + elapsedTime: Duration(minutes: 20), + isDone: true, + ), + ], + ), + ); + + for (final (hasRuntime, difference) in [ + (true, Duration.zero), + (false, Duration.zero), + (true, const Duration(minutes: 1)), + ]) { + final validated = hasRuntime && difference == Duration.zero; + fakeClockTest( + 'late durable run runtime=$hasRuntime start difference=$difference is not newly started on resume', + (tester) async { + final nearest = _Nearest(active()); + final session = _Session() + ..hasValidatedRuntime = hasRuntime + ..restoredStartDifference = difference; + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.schedule?.id, 'active'); + expect( + bloc.state.status, + validated ? ScheduleStatus.started : ScheduleStatus.upcoming, + ); + expect(bloc.state.schedule?.requiresStartConfirmation, !validated); + expect(bloc.state.isResumedPreparation, validated); + expect(session.starts, isEmpty); + expect(navigation.routes, isEmpty); + bloc.observeLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(nearest.sources, hasLength(2)); + expect(bloc.state.schedule?.id, 'active'); + expect(session.starts, isEmpty); + expect(navigation.routes, isEmpty); + } finally { + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }, + ); + } + + fakeClockTest( + 'an authoritative missing active read releases runtime references without durable cleanup', + (tester) async { + final nearest = _Nearest(active()); + final session = _Session(); + final bloc = ScheduleBloc.test( + nearest, + _Navigation(), + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.schedule?.id, 'active'); + nearest.value = null; + nearest.sources.last.add(null); + await tester.pump(); + expect(bloc.state.status, ScheduleStatus.notExists); + expect(session.clears, isEmpty); + expect(session.starts, isEmpty); + } finally { + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }, + ); + fakeClockTest( + 'a retired query cannot restore its pending active run over a replacement missing candidate', + (tester) async { + final nearest = _Nearest(active()); + final pending = Completer(); + final session = _Session()..pendingRestore = pending; + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + nearest.value = null; + bloc.observeLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(nearest.sources, hasLength(2)); + expect(bloc.state.status, ScheduleStatus.notExists); + pending.complete(); + await tester.pump(); + expect(bloc.state.status, ScheduleStatus.notExists); + expect(session.starts, isEmpty); + expect(navigation.routes, isEmpty); + } finally { + if (!pending.isCompleted) pending.complete(); + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }, + ); + fakeClockTest( + 'read errors retain the validated active run until a real missing result arrives', + (tester) async { + final nearest = _Nearest(active()); + final session = _Session(); + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + final original = bloc.state.schedule; + nearest.sources.last.addError(StateError('temporary read failure')); + await tester.pump(); + expect(bloc.state.hasUpcomingReadFailure, isTrue); + expect(bloc.state.schedule, original); + expect(bloc.state.status, ScheduleStatus.started); + expect(session.clears, isEmpty); + expect(session.starts, isEmpty); + expect(navigation.routes, isEmpty); + nearest.sources.last.add(null); + await tester.pump(); + expect(bloc.state.hasUpcomingReadFailure, isFalse); + expect(bloc.state.status, ScheduleStatus.notExists); + expect(session.clears, isEmpty); + expect(session.starts, isEmpty); + } finally { + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }, + ); + + fakeClockTest( + 'a failed future projection cannot automatically or explicitly start before a successful retry', + (tester) async { + final future = ScheduleWithPreparationEntity( + id: 'future', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Future appointment', + scheduleTime: now.add(const Duration(minutes: 21)), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + isChanged: false, + isStarted: false, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prepare', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 20), + nextPreparationId: null, + ), + ], + ), + ); + final nearest = _Nearest(future); + final session = _Session()..hasValidatedRuntime = false; + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.status, ScheduleStatus.upcoming); + final initialClears = List.of(session.clears); + nearest.sources.last.addError(StateError('read unavailable')); + await tester.pump(); + expect(bloc.state.hasUpcomingReadFailure, isTrue); + bloc.add(const ScheduleStarted()); + final receipt = bloc.requestPreparationStart(isCurrent: () => true); + await tester.pump(const Duration(minutes: 2)); + await tester.complete(receipt.then((value) => expect(value, isNull))); + expect(session.starts, isEmpty); + expect(navigation.routes, isEmpty); + expect(session.clears, initialClears); + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.hasUpcomingReadFailure, isFalse); + expect(bloc.state.schedule?.id, 'future'); + expect(nearest.sources, hasLength(2)); + } finally { + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }, + ); + + fakeClockTest('a pending restore cannot erase a newer read failure', ( + tester, + ) async { + final nearest = _Nearest(active()); + final pending = Completer(); + final session = _Session()..pendingRestore = pending; + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + nearest.sources.last.addError(StateError('newer source failure')); + await tester.pump(); + expect(bloc.state.hasUpcomingReadFailure, isTrue); + pending.complete(); + await tester.pump(); + expect(bloc.state.hasUpcomingReadFailure, isTrue); + expect(bloc.state.status, ScheduleStatus.initial); + expect(session.starts, isEmpty); + expect(navigation.routes, isEmpty); + expect(session.clears, isEmpty); + } finally { + if (!pending.isCompleted) pending.complete(); + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }); + fakeClockTest( + 'initial durable active recovery survives terminal nearest budget limit', + (tester) async { + final nearest = _Nearest(active()) + ..initialActiveRead = true + ..terminalLimited = true; + final session = _Session(); + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => now, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.nearestQuery, isA()); + expect(bloc.state.schedule?.id, 'active'); + expect(bloc.state.hasOwnedPreparationSurface, isTrue); + expect(bloc.state.status, ScheduleStatus.started); + expect(session.starts, isEmpty); + expect(session.clears, isEmpty); + expect(navigation.routes, isEmpty); + } finally { + await tester.complete(bloc.close()); + for (final source in nearest.sources) { + await tester.complete(source.close()); + } + } + }, + ); +} diff --git a/test/presentation/app/bloc/schedule/nearest_boundary_intent_test.dart b/test/presentation/app/bloc/schedule/nearest_boundary_intent_test.dart new file mode 100644 index 00000000..dd38990d --- /dev/null +++ b/test/presentation/app/bloc/schedule/nearest_boundary_intent_test.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_start_rejected.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import '../../../../helpers/fake_clock.dart'; +import '../../../../helpers/nearest_query_fixture.dart'; + +class _Nearest implements GetNearestUpcomingScheduleUseCase { + _Nearest(this.value); + final ScheduleWithPreparationEntity value; + final source = StreamController(); + NearestQueryKey? key; + @override + Stream call({required NearestQueryKey key}) { + this.key = key; + source.add(nearestQueryFixture(value, key)); + return source.stream; + } + + @override + Future readActive() async => null; +} + +class _Session extends Fake implements SchedulePreparationSessionUseCase { + int entered = 0; + int writes = 0; + Completer? pending; + String? fingerprint; + DateTime? committedAt; + @override + Future startSchedulePreparation( + String id, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) async { + entered++; + fingerprint = expectedFingerprint; + await pending?.future; + if (!(isCurrent?.call() ?? true)) throw ScheduleStartRejected(id); + writes++; + + return committedAt ?? DateTime.now().toUtc(); + } +} + +class _Navigation extends NavigationService { + final routes = []; + @override + void push(String routeName, {Object? extra}) => routes.add(routeName); +} + +ScheduleWithPreparationEntity _schedule(DateTime start) => + ScheduleWithPreparationEntity( + id: 'next', + place: const PlaceEntity(id: 'p', placeName: 'Office'), + scheduleName: 'Next', + scheduleTime: start.add(const Duration(minutes: 10)), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'step', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final delay in [ + const Duration(microseconds: -1), + Duration.zero, + const Duration(seconds: 2), + const Duration(seconds: 2, microseconds: 1), + ]) { + fakeClockTest( + 'owned future boundary lateness $delay respects inclusive two-second policy', + (tester) async { + var wall = DateTime.utc(2030, 1, 1, 12); + var mono = Duration.zero; + final start = wall.add(const Duration(seconds: 10)); + final nearest = _Nearest(_schedule(start)); + final session = _Session(); + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => wall, + monotonicNow: () => mono, + ); + try { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.freshNearest, isNotNull); + session.committedAt = start.add(delay); + wall = start.add(delay); + mono = const Duration(seconds: 10) + delay; + await tester.pump(const Duration(seconds: 10)); + final accepted = + delay >= Duration.zero && delay <= const Duration(seconds: 2); + expect(session.writes, accepted ? 1 : 0); + expect(navigation.routes, accepted ? ['/scheduleStart'] : isEmpty); + if (accepted) { + expect(session.fingerprint, nearest.value.cacheFingerprint); + } + } finally { + await tester.complete(bloc.close()); + await tester.complete(nearest.source.close()); + } + }, + ); + } + + fakeClockTest( + 'query receipt after preparation start does not catch up or write', + (tester) async { + final wall = DateTime.utc(2030, 1, 1, 12); + final nearest = _Nearest( + _schedule(wall.subtract(const Duration(seconds: 1))), + ); + final session = _Session(); + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => wall, + ); + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.freshNearest, isNotNull); + expect(session.entered, 0); + expect(navigation.routes, isEmpty); + await tester.complete(bloc.close()); + await tester.complete(nearest.source.close()); + }, + ); + + fakeClockTest( + 'pause while owned start writer is pending discards without unhandled failure or navigation', + (tester) async { + var wall = DateTime.utc(2030, 1, 1, 12); + var mono = Duration.zero; + final nearest = _Nearest( + _schedule(wall.add(const Duration(seconds: 10))), + ); + final pending = Completer(); + final session = _Session()..pending = pending; + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + nearest, + navigation, + session, + nowProvider: () => wall, + monotonicNow: () => mono, + ); + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + wall = wall.add(const Duration(seconds: 10)); + mono = const Duration(seconds: 10); + await tester.pump(const Duration(seconds: 10)); + expect(session.entered, 1); + bloc.observeLifecycleState(AppLifecycleState.paused); + pending.complete(); + await tester.pump(); + expect(session.writes, 0); + expect(navigation.routes, isEmpty); + await tester.complete(bloc.close()); + await tester.complete(nearest.source.close()); + }, + ); +} diff --git a/test/presentation/app/bloc/schedule/nearest_cancel_delivery_race_test.dart b/test/presentation/app/bloc/schedule/nearest_cancel_delivery_race_test.dart new file mode 100644 index 00000000..c9acdd39 --- /dev/null +++ b/test/presentation/app/bloc/schedule/nearest_cancel_delivery_race_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/nearest_schedule_query_repository_impl.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +class _ObservedRepository implements NearestScheduleQueryRepository { + _ObservedRepository(this.delegate); + final NearestScheduleQueryRepository delegate; + int opens = 0; + @override + Stream get changes => delegate.changes; + @override + Future open(NearestQueryKey key) { + opens++; + return delegate.open(key); + } + + @override + Future readActive() => delegate.readActive(); +} + +// Hold no result and synthesize no query. This hook creates the exact queue +// ordering: a UI Cancel is enqueued immediately before an already completed +// production query reaches the Bloc listener. It fires only once. +class _CancelBeforeTerminal implements GetNearestUpcomingScheduleUseCase { + _CancelBeforeTerminal(this.delegate, this.onTerminal); + final GetNearestUpcomingScheduleUseCase delegate; + final void Function(NearestQueryKey) onTerminal; + bool intercepted = false; + @override + Future readActive() => delegate.readActive(); + @override + Stream call({required NearestQueryKey key}) => + delegate(key: key).map((result) { + if (!intercepted && + (result is NearestQueryEmpty || result is NearestQueryReady)) { + intercepted = true; + onTerminal(result.key!); + } + return result; + }); +} + +class _Session extends Fake implements SchedulePreparationSessionUseCase {} + +ScheduleEntity _schedule(String id, DateTime instant) => ScheduleEntity( + id: id, + place: PlaceEntity(id: 'place-$id', placeName: 'Synthetic place'), + scheduleName: id, + scheduleTime: instant, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); +Future _databaseTurns(AppDatabase db) async { + for (var turn = 0; turn < 5; turn++) { + await db.customSelect('SELECT 1').get(); + await Future.delayed(Duration.zero); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final changedWhilePaused in [false, true]) { + test( + 'actual producer revalidates a terminal delivered beside Cancel with paused data change=$changedWhilePaused', + () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final now = DateTime.utc(2030, 1, 1, 12); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + if (changedWhilePaused) { + await db.scheduleDao.createSchedule( + _schedule( + 'old', + now.add(const Duration(days: 3)), + ).toScheduleWithPlaceRow(), + ); + } + final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + final repository = _ObservedRepository( + NearestScheduleQueryRepositoryImpl(db, recurring, now: () => now), + ); + late ScheduleBloc bloc; + final nearest = _CancelBeforeTerminal( + GetNearestUpcomingScheduleUseCase(repository, now: () => now), + (key) => bloc.add(ScheduleNearestQueryCancelRequested(key)), + ); + bloc = ScheduleBloc.test( + nearest, + NavigationService(), + _Session(), + nowProvider: () => now, + ); + final seen = []; + final observed = bloc.stream.listen( + (state) => seen.add(state.nearestQuery), + ); + try { + final cancelled = bloc.stream.firstWhere( + (state) => + state.nearestQuery is NearestQueryLimited && + (state.nearestQuery as NearestQueryLimited).reason == + NearestQueryLimitReason.cancelled, + ); + bloc.add(const ScheduleSubscriptionRequested()); + final cancelledState = await cancelled.timeout( + const Duration(seconds: 5), + ); + await _databaseTurns(db); + final pausedKey = cancelledState.nearestQuery.key!; + final opensBeforeContinue = repository.opens; + expect(nearest.intercepted, isTrue); + expect(bloc.state.nearestQuery, isA()); + if (changedWhilePaused) { + await db.scheduleDao.createSchedule( + _schedule( + 'new', + now.add(const Duration(days: 1)), + ).toScheduleWithPlaceRow(), + ); + await _databaseTurns(db); + } + final settled = bloc.stream.firstWhere( + (state) => changedWhilePaused + ? state.nearestQuery is NearestQueryReady + : state.nearestQuery is NearestQueryEmpty, + ); + bloc.add(ScheduleNearestQueryContinueRequested(pausedKey)); + final result = (await settled.timeout( + const Duration(seconds: 5), + )).nearestQuery; + if (changedWhilePaused) { + expect((result as NearestQueryReady).value.schedule.id, 'new'); + expect( + seen.whereType().where( + (v) => v.value.schedule.id == 'old', + ), + isEmpty, + reason: + 'An old terminal DTO must not be replayed as fresh authority before current SQLite validation.', + ); + } else { + expect(result.key, pausedKey); + expect( + repository.opens, + opensBeforeContinue, + reason: + 'An unchanged completed producer rechecks the same session; Continue must not reset its budget by opening again.', + ); + } + } finally { + await observed.cancel(); + await bloc.close(); + } + }, + ); + } +} diff --git a/test/presentation/app/bloc/schedule/nearest_committed_start_projection_test.dart b/test/presentation/app/bloc/schedule/nearest_committed_start_projection_test.dart new file mode 100644 index 00000000..f62b4e7c --- /dev/null +++ b/test/presentation/app/bloc/schedule/nearest_committed_start_projection_test.dart @@ -0,0 +1,332 @@ +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/data/data_sources/early_start_session_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_with_time_local_data_source.dart'; +import 'package:on_time_front/data/repositories/early_start_session_repository_impl.dart'; +import 'package:on_time_front/data/repositories/nearest_schedule_query_repository_impl.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/timed_preparation_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/nearest_schedule_query_repository.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/home/utils/home_schedule_card_selection.dart'; +import '../../../../helpers/fake_clock.dart'; +import '../../../../helpers/noop_alarm_reconciliation.dart'; + +class _Preparation extends Fake implements PreparationRepository {} + +class _Cancel extends Fake implements CancelScheduleAlarmUseCase {} + +// Native database work can yield through zero-duration timers as well as +// microtasks. Drain those timers without advancing the observed wall clock or +// the owned automatic-start boundary, and report which operation was pending. +Future _settle( + FakeClock tester, + Future operation, + String stage, +) async { + var completed = false; + T? value; + Object? failure; + StackTrace? failureStack; + operation.then( + (result) { + value = result; + completed = true; + }, + onError: (Object error, StackTrace stack) { + failure = error; + failureStack = stack; + completed = true; + }, + ); + for (var turn = 0; !completed && turn < 100; turn++) { + await tester.pump(); + } + if (failure != null) { + Error.throwWithStackTrace(failure!, failureStack!); + } + if (!completed) { + fail('Connected start stage did not settle: $stage'); + } + return value as T; +} + +class _Navigation extends NavigationService { + final routes = []; + @override + void push(String routeName, {Object? extra}) => routes.add(routeName); +} + +// Delays only the real DAO signal. Query contents, active reads and writes all +// still come from the actual SQLite adapter and production use cases. +class _DelayedWatch implements NearestScheduleQueryRepository { + _DelayedWatch(this.delegate); + final NearestScheduleQueryRepository delegate; + final released = Completer(); + @override + Stream get changes => delegate.changes.asyncMap((_) => released.future); + @override + Future open(NearestQueryKey key) => + delegate.open(key); + @override + Future readActive() => delegate.readActive(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final lateness in [Duration.zero, const Duration(seconds: 2)]) { + fakeClockTest( + 'actual committed start at lateness $lateness owns Home before delayed watch and retains its runtime anchor', + (tester) async { + SharedPreferences.setMockInitialValues({}); + var wall = DateTime.utc(2030, 1, 1, 12); + var mono = Duration.zero; + final target = wall.add(const Duration(seconds: 10)); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + ScheduleRepositoryImpl? schedulesToDispose; + SchedulePreparationSessionUseCase? sessionToDispose; + AlarmOperationCoordinator? operationsToDispose; + _DelayedWatch? watchToRelease; + ScheduleBloc? blocToClose; + StreamSubscription? observationToCancel; + try { + await _settle( + tester, + db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ), + 'seed profile', + ); + await _settle( + tester, + RestoreRuntimeIdentity.shared.load(db), + 'runtime identity', + ); + final runtime = TimedPreparationRepositoryImpl( + localDataSource: PreparationWithTimeLocalDataSourceImpl(), + ); + final early = EarlyStartSessionRepositoryImpl( + localDataSource: EarlyStartSessionLocalDataSourceImpl(), + ); + final recurring = RecurringScheduleRepositoryImpl( + db, + now: () => wall, + ); + final schedules = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: runtime, + recurringScheduleRepository: recurring, + now: () => wall, + ); + schedulesToDispose = schedules; + await _settle( + tester, + schedules.createSchedule( + ScheduleEntity( + id: 'appointment', + place: const PlaceEntity(id: 'p', placeName: 'Office'), + scheduleName: 'Appointment', + scheduleTime: target.add(const Duration(minutes: 10)), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: true, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ), + ), + 'seed schedule', + ); + await _settle( + tester, + db.preparationScheduleDao.createPreparationSchedule( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + ), + ], + ), + 'appointment', + ), + 'seed preparation', + ); + final beforeRevision = (await _settle( + tester, + db.select(db.users).getSingle(), + 'baseline revision', + )).dataRevision; + final operations = AlarmOperationCoordinator( + LocalDataOperationGate.shared, + ); + operationsToDispose = operations; + final session = SchedulePreparationSessionUseCase( + schedules, + _Preparation(), + runtime, + early, + _Cancel(), + NoopAlarmReconciliation(), + operations: operations, + ); + sessionToDispose = session; + final delayed = _DelayedWatch( + NearestScheduleQueryRepositoryImpl(db, recurring, now: () => wall), + ); + watchToRelease = delayed; + final navigation = _Navigation(); + final bloc = ScheduleBloc.test( + GetNearestUpcomingScheduleUseCase(delayed, now: () => wall), + navigation, + session, + nowProvider: () => wall, + monotonicNow: () => mono, + ); + blocToClose = bloc; + final ready = Completer(); + final started = Completer(); + final restored = Completer(); + final observed = bloc.stream.listen((state) { + if (state.status == ScheduleStatus.upcoming && + state.freshNearest != null && + !ready.isCompleted) { + ready.complete(); + } + if (state.hasOwnedPreparationSurface && + state.status == ScheduleStatus.started && + !started.isCompleted) { + started.complete(); + } + if (state.isResumedPreparation && !restored.isCompleted) { + restored.complete(); + } + }); + observationToCancel = observed; + bloc.add(const ScheduleSubscriptionRequested()); + await _settle(tester, ready.future, 'initial ready'); + wall = target.add(lateness); + mono = const Duration(seconds: 10) + lateness; + await tester.pump(const Duration(seconds: 10)); + await _settle(tester, started.future, 'committed start'); + expect(delayed.released.isCompleted, isFalse); + final actual = await _settle( + tester, + schedules.getScheduleById('appointment'), + 'read committed row', + ); + expect(actual.startedAt!.toUtc(), wall); + expect( + bloc.state.schedule!.startedAt!.toUtc(), + actual.startedAt!.toUtc(), + ); + expect(bloc.state.schedule!.preparationFrozen, isTrue); + expect( + HomeScheduleCardSelection.at(bloc.state, wall).kind, + HomeScheduleCardKind.active, + ); + expect(navigation.routes, ['/scheduleStart']); + await tester.pump(); + await _settle( + tester, + operations.run(operations.capture(), () async {}), + 'flush runtime writer', + ); + final snapshot = await _settle( + tester, + runtime.getTimedPreparationSnapshot('appointment'), + 'read runtime snapshot', + ); + expect(snapshot, isNotNull); + expect(snapshot!.startedAt!.toUtc(), actual.startedAt!.toUtc()); + expect(bloc.state.schedule!.preparation.elapsedTime, Duration.zero); + wall = wall.add(const Duration(seconds: 5)); + mono += const Duration(seconds: 5); + delayed.released.complete(); + await _settle(tester, restored.future, 'active watch recovery'); + await tester.pump(); + expect( + HomeScheduleCardSelection.at(bloc.state, wall).kind, + HomeScheduleCardKind.active, + ); + expect( + bloc.state.schedule!.preparation.elapsedTime, + const Duration(seconds: 5), + ); + expect(bloc.state.schedule!.requiresStartConfirmation, isFalse); + expect( + (await _settle( + tester, + schedules.getScheduleById('appointment'), + 'read retained anchor', + )).startedAt!.toUtc(), + actual.startedAt!.toUtc(), + ); + expect( + (await _settle( + tester, + db.select(db.users).getSingle(), + 'final revision', + )).dataRevision, + beforeRevision + 1, + ); + expect(navigation.routes, ['/scheduleStart']); + } finally { + final watch = watchToRelease; + if (watch != null && !watch.released.isCompleted) { + watch.released.complete(); + } + try { + final observation = observationToCancel; + if (observation != null) { + await _settle(tester, observation.cancel(), 'cancel observation'); + } + final bloc = blocToClose; + if (bloc != null) { + await _settle(tester, bloc.close(), 'close bloc'); + } + } finally { + sessionToDispose?.dispose(); + operationsToDispose?.dispose(); + try { + final schedules = schedulesToDispose; + if (schedules != null) { + await _settle( + tester, + schedules.dispose(), + 'close schedule repository', + ); + } + } finally { + await _settle(tester, db.close(), 'close database'); + } + } + } + }, + ); + } +} diff --git a/test/presentation/app/bloc/schedule/nearest_query_lifecycle_test.dart b/test/presentation/app/bloc/schedule/nearest_query_lifecycle_test.dart new file mode 100644 index 00000000..a8acf637 --- /dev/null +++ b/test/presentation/app/bloc/schedule/nearest_query_lifecycle_test.dart @@ -0,0 +1,187 @@ +import '../../../../helpers/nearest_query_fixture.dart'; +import 'dart:async'; + +import '../../../../helpers/fake_clock.dart'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +class _Nearest implements GetNearestUpcomingScheduleUseCase { + final streams = >[]; + var cancellations = 0; + Future Function()? activeRead; + @override + Future readActive() async => + activeRead == null ? null : await activeRead!(); + @override + Stream call({required NearestQueryKey key}) { + final source = StreamController( + onCancel: () { + cancellations++; + }, + ); + streams.add(source); + source.add(null); + return source.stream.map((value) => nearestQueryFixture(value, key)); + } +} + +class _Session extends Fake implements SchedulePreparationSessionUseCase {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _Nearest nearest; + late ScheduleBloc bloc; + late DateTime now; + void lifecycleTest( + String description, + Future Function(FakeClock) body, + ) { + fakeClockTest(description, (tester) async { + nearest = _Nearest(); + now = DateTime(2030, 1, 1, 23, 59, 59); + bloc = ScheduleBloc.test( + nearest, + NavigationService(), + _Session(), + nowProvider: () => now, + ); + try { + await body(tester); + } finally { + await tester.complete(bloc.close()); + for (final source in nearest.streams) { + await tester.complete(source.close()); + } + } + }); + } + + lifecycleTest('device midnight replaces the query without a database event', ( + tester, + ) async { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(nearest.streams, hasLength(1)); + now = now.add(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(nearest.streams, hasLength(2)); + expect(nearest.cancellations, 1); + expect(bloc.state.status, ScheduleStatus.notExists); + }); + + lifecycleTest('resume renews an existing query and paused phases do not', ( + tester, + ) async { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + bloc.observeLifecycleState(AppLifecycleState.paused); + await tester.pump(); + expect(nearest.streams, hasLength(1)); + now = DateTime(2030, 1, 5, 8); + bloc.observeLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(nearest.streams, hasLength(2)); + expect(nearest.cancellations, 1); + }); + + lifecycleTest( + 'foreground date discontinuity renews the query on the clock check', + (tester) async { + now = DateTime(2030, 1, 1, 10); + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + now = DateTime(2029, 12, 31, 10); + await tester.pump(const Duration(minutes: 1)); + expect(nearest.streams, hasLength(2)); + await tester.pump(const Duration(minutes: 1)); + expect(nearest.streams, hasLength(2)); + }, + ); + + lifecycleTest( + 'close cancels midnight, foreground clock and live query owners', + (tester) async { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + await tester.complete(bloc.close()); + now = DateTime(2030, 1, 4); + await tester.pump(const Duration(days: 3)); + expect(nearest.streams, hasLength(1)); + expect(nearest.cancellations, 1); + }, + ); + lifecycleTest( + 'same civil day clock jump refreshes and named-zone change refreshes once', + (tester) async { + now = DateTime(2030, 1, 1, 10); + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + now = now.add(const Duration(hours: 1)); + await tester.pump(const Duration(minutes: 1)); + expect(nearest.streams, hasLength(2)); + bloc.observeDeviceTimeZone('Asia/Seoul'); + bloc.observeDeviceTimeZone('Asia/Tokyo'); + await tester.pump(); + expect(nearest.streams, hasLength(3)); + bloc.observeDeviceTimeZone('Asia/Tokyo'); + await tester.pump(); + expect(nearest.streams, hasLength(3)); + }, + ); + + lifecycleTest( + 'pause cancels query heartbeat across midnight and resume issues exactly one fresh query', + (tester) async { + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + bloc.observeLifecycleState(AppLifecycleState.paused); + await tester.pump(); + now = now.add(const Duration(days: 2)); + await tester.pump(const Duration(days: 2)); + expect(nearest.streams, hasLength(1)); + expect(nearest.cancellations, 1); + bloc.observeLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(nearest.streams, hasLength(2)); + }, + ); + for (final paused in [false, true]) { + lifecycleTest( + 'late rejected initial active read cannot install a retired query after ${paused ? "pause" : "newer subscription"}', + (tester) async { + final pending = Completer(); + var reads = 0; + nearest.activeRead = () => + ++reads == 1 ? pending.future : Future.value(null); + bloc.add(const ScheduleSubscriptionRequested()); + await tester.pump(); + expect(nearest.streams, isEmpty); + if (paused) { + bloc.observeLifecycleState(AppLifecycleState.paused); + } else { + bloc.add(const ScheduleSubscriptionRequested()); + } + await tester.pump(); + expect(nearest.streams, hasLength(paused ? 0 : 1)); + pending.completeError(StateError('retired active read failed')); + await tester.pump(); + expect(nearest.streams, hasLength(paused ? 0 : 1)); + bloc.observeLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(nearest.streams, hasLength(paused ? 1 : 2)); + expect( + nearest.cancellations, + paused ? 0 : 1, + reason: + 'The latest subscription handle remains owned and is cancelled exactly once.', + ); + }, + ); + } +} diff --git a/test/presentation/app/bloc/schedule/notification_prompt_race_test.dart b/test/presentation/app/bloc/schedule/notification_prompt_race_test.dart index c3308ec6..577defd3 100644 --- a/test/presentation/app/bloc/schedule/notification_prompt_race_test.dart +++ b/test/presentation/app/bloc/schedule/notification_prompt_race_test.dart @@ -34,7 +34,8 @@ void main() { id: past.id, place: past.place, scheduleName: past.scheduleName, - scheduleTime: DateTime(2000), + scheduleTime: DateTime.utc(2000), + occurrenceOffsetSeconds: 0, moveTime: past.moveTime, isChanged: false, isStarted: false, @@ -67,7 +68,10 @@ void main() { class _Nearest implements GetNearestUpcomingScheduleUseCase { @override - Stream call() => const Stream.empty(); + Stream call({required NearestQueryKey key}) => + const Stream.empty(); + @override + Future readActive() async => null; } class _Navigation extends NavigationService { @@ -116,8 +120,14 @@ class _Session implements SchedulePreparationSessionUseCase { } @override - Future startSchedulePreparation(String id) async { + Future startSchedulePreparation( + String id, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) async { starts++; + + return DateTime.now().toUtc(); } @override diff --git a/test/presentation/app/bloc/schedule/preparation_step_delivery_test.dart b/test/presentation/app/bloc/schedule/preparation_step_delivery_test.dart index 7888289d..8689fb71 100644 --- a/test/presentation/app/bloc/schedule/preparation_step_delivery_test.dart +++ b/test/presentation/app/bloc/schedule/preparation_step_delivery_test.dart @@ -8,6 +8,7 @@ import 'package:on_time_front/core/services/notification_service.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; @@ -28,7 +29,16 @@ class _Session implements SchedulePreparationSessionUseCase { required DateTime now, RestoredSessionCallback? onRestoredSession, void Function()? onInvalidated, - }) async => schedule; + }) async { + final startedAt = schedule.startedAt!; + onRestoredSession?.call(startedAt: startedAt, actionEvents: const []); + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + schedule.preparation.timeElapsed(now.difference(startedAt)), + timeResolution: ScheduleTimeResolver.resolve(schedule, nowUtc: now), + ); + } + @override Future saveTimedPreparationSnapshot( ScheduleWithPreparationEntity schedule, { @@ -45,14 +55,24 @@ class _Session implements SchedulePreparationSessionUseCase { required int latenessTime, }) async {} @override - Future startSchedulePreparation(String id) async {} + Future startSchedulePreparation( + String id, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) async { + return DateTime.now().toUtc(); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _Nearest implements GetNearestUpcomingScheduleUseCase { @override - Stream call() => const Stream.empty(); + Stream call({required NearestQueryKey key}) => + const Stream.empty(); + @override + Future readActive() async => null; } class _Navigation extends NavigationService { @@ -116,17 +136,27 @@ class _Harness { nextPreparationId: i == count ? null : 's${i + 1}', ), ]; - bloc.add( - ScheduleUpcomingReceived( - buildSchedule( - id: id, - scheduleTime: wall.add(Duration(seconds: count * 3 - elapsed)), - moveTime: Duration.zero, - scheduleSpareTime: Duration.zero, - steps: steps, - ), - ), + final planned = buildSchedule( + id: id, + scheduleTime: wall.add(Duration(seconds: count * 3 - elapsed)), + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + steps: steps, ); + // This harness exercises delivery during an existing durable run, not a + // recommendation's ability to start one merely by being read. + final startedAt = wall.subtract(Duration(seconds: elapsed)).toUtc(); + final active = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + planned.copyWith( + isStarted: true, + startedAt: startedAt, + preparationFrozen: true, + ), + planned.preparation, + timeResolution: ScheduleTimeResolver.resolve(planned, nowUtc: wall), + ); + bloc.add(ScheduleUpcomingReceived(active)); await tester.pump(); } diff --git a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart index 247a82bb..ccf11546 100644 --- a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart +++ b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart @@ -1,3 +1,8 @@ +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import '../../../../helpers/nearest_query_fixture.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; import 'dart:async'; import 'package:on_time_front/domain/entities/preparation_snapshot_validation.dart'; @@ -36,7 +41,20 @@ class StubGetNearestUpcomingScheduleUseCase final Stream Function() streamFactory; @override - Stream call() => streamFactory(); + Stream call({required NearestQueryKey key}) => + streamFactory().map((value) { + _last = value; + return nearestQueryFixture(value, key); + }); + ScheduleWithPreparationEntity? _last; + @override + Future readActive() async => + _last != null && + _last!.isStarted && + _last!.startedAt != null && + _last!.preparationFrozen + ? _last + : null; } class SpyNavigationService extends NavigationService { @@ -206,6 +224,20 @@ class SpyClearEarlyStartSessionUseCase class FakeSchedulePreparationSessionUseCase implements SchedulePreparationSessionUseCase { @override + Future assertDeletionAllowed( + ScheduleEditSnapshot snapshot, { + required AlarmOperationLease lease, + }) => + throw UnimplementedError('Deletion is outside this preparation fixture'); + @override + Future clearDeletedStateUnderOwner( + String scheduleId, { + required AlarmOperationLease lease, + required Future Function() isCurrent, + }) => + throw UnimplementedError('Deletion is outside this preparation fixture'); + + @override void dispose() {} FakeSchedulePreparationSessionUseCase({ @@ -262,9 +294,15 @@ class FakeSchedulePreparationSessionUseCase } @override - Future startSchedulePreparation(String scheduleId) async { - if (!_startedScheduleIds.add(scheduleId)) return; + Future startSchedulePreparation( + String scheduleId, { + bool Function()? isCurrent, + String? expectedFingerprint, + }) async { + if (!_startedScheduleIds.add(scheduleId)) return DateTime.now().toUtc(); await startScheduleUseCase(scheduleId); + + return DateTime.now().toUtc(); } @override @@ -326,6 +364,10 @@ class FakeSchedulePreparationSessionUseCase return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, restoredPreparation, + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now().toUtc(), + ), ); } @@ -369,6 +411,10 @@ class FakeSchedulePreparationSessionUseCase ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( schedule, PreparationWithTimeEntity.fromPreparation(preparation), + timeResolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.now().toUtc(), + ), ); if (scheduleFingerprint != null && scheduleFingerprint != scheduleWithPreparation.cacheFingerprint && @@ -490,7 +536,10 @@ ScheduleWithPreparationEntity buildSchedule({ id: id, place: PlaceEntity(id: 'p1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: scheduleTime, + // Caller supplies an instant; this fixture stores explicit UTC wall fields. + scheduleTime: scheduleTime.toUtc(), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, moveTime: moveTime, isChanged: false, isStarted: false, @@ -596,11 +645,173 @@ void main() { ); }); + // Runtime behavior tests begin with persisted start authority and a + // matching reconstructible snapshot. A recommendation alone cannot start. + void receiveDurableRun(ScheduleWithPreparationEntity schedule) { + final prior = getSnapshotUseCase.snapshots[schedule.id]; + final startedAt = + prior?.startedAt ?? + (schedule.preparation.isAllStepsDone + ? now.subtract(schedule.preparation.elapsedTime) + : schedule.preparationStartTime); + final active = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule.copyWith( + isStarted: true, + startedAt: startedAt, + preparationFrozen: true, + ), + schedule.preparation, + timeResolution: ScheduleTimeResolver.resolve(schedule, nowUtc: now), + ); + getSnapshotUseCase.snapshots.putIfAbsent( + schedule.id, + () => buildSnapshot( + preparation: schedule.preparation, + savedAt: startedAt, + fingerprint: active.cacheFingerprint, + startedAt: startedAt, + ), + ); + expect( + getSnapshotUseCase.snapshots[schedule.id]!.scheduleFingerprint, + active.cacheFingerprint, + ); + bloc.add(ScheduleUpcomingReceived(active)); + } + + Future followQueryWithLiveClock() async { + await bloc.close(); + final base = now; + final clock = Stopwatch()..start(); + bloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => controller.stream), + navigationService, + sessionUseCase, + nowProvider: () => base.add(clock.elapsed), + monotonicNow: () => clock.elapsed, + ); + bloc.add(const ScheduleSubscriptionRequested()); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + } + tearDown(() async { await bloc.close(); await controller.close(); }); + test( + 'unresolved time blocks automation without blocking the next valid appointment', + () async { + final good = buildSchedule( + id: 'good', + scheduleTime: now.add(const Duration(hours: 2)), + steps: const [], + ); + for (final raw in [ + good.copyWith(timeZoneId: 'Missing/Zone'), + ScheduleEntity( + id: 'legacy-null', + place: good.place, + scheduleName: 'Legacy', + scheduleTime: DateTime.utc(2000), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: null, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ), + good.copyWith(occurrenceOffsetSeconds: 3600), + ]) { + final problem = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + raw, + good.preparation, + timeResolution: ScheduleTimeResolver.resolve(raw, nowUtc: now), + ); + expect(problem.timeResolution!.instantUtc, isNull); + bloc.add(ScheduleUpcomingReceived(problem)); + await pumpEventQueue(); + expect(bloc.state.status, ScheduleStatus.notExists); + expect(startUseCase.calls, isEmpty); + expect(finishUseCase.calls, isEmpty); + expect(notifiedStepIds, isEmpty); + } + bloc.add(ScheduleUpcomingReceived(good)); + await pumpEventQueue(); + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule!.id, 'good'); + }, + ); + + test( + 'restored past-due preparation stays upcoming without run, actions, or timer', + () async { + final source = buildSchedule( + id: 'restored', + scheduleTime: now.add(const Duration(minutes: 5)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'step', + preparationName: 'step', + preparationTime: Duration(minutes: 20), + nextPreparationId: null, + ), + ], + ); + final restored = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + source.copyWith( + requiresStartConfirmation: true, + startedAt: now.subtract(const Duration(days: 1)), + preparationFrozen: true, + ), + source.preparation, + timeResolution: ScheduleTimeResolver.resolve( + source.copyWith( + requiresStartConfirmation: true, + startedAt: now.subtract(const Duration(days: 1)), + preparationFrozen: true, + ), + nowUtc: DateTime.now().toUtc(), + ), + ); + bloc.add(ScheduleUpcomingReceived(restored)); + await pumpEventQueue(); + bloc.add(const ScheduleStarted()); + bloc.add(const ScheduleTick(Duration(minutes: 10))); + await pumpEventQueue(); + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule!.preparation.elapsedTime, Duration.zero); + expect(startUseCase.calls, isEmpty); + expect(saveUseCase.calls, isEmpty); + expect(finishUseCase.calls, isEmpty); + }, + ); + + test( + 'queued old repository event cannot repopulate the replaced generation', + () async { + final old = LocalDataOperationGate.shared.generation; + await LocalDataOperationGate.shared.run( + () async {}, + replacesData: true, + ); + final source = buildSchedule( + id: 'old', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [], + ); + bloc.add(ScheduleUpcomingReceived(source, generation: old)); + await pumpEventQueue(); + expect(bloc.state.schedule?.id, isNot('old')); + expect(startUseCase.calls, isEmpty); + }, + ); + test('emits notExists when there is no upcoming schedule', () async { bloc.add(const ScheduleUpcomingReceived(null)); await Future.delayed(Duration.zero); @@ -702,25 +913,30 @@ void main() { expect(bloc.state.schedule?.id, 'upcoming'); }); - test('emits ongoing when now is inside preparation window', () async { - final schedule = buildSchedule( - id: 'ongoing', - scheduleTime: now.add(const Duration(minutes: 30)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + test( + 'reading a past preparation boundary stays upcoming without a start', + () async { + final schedule = buildSchedule( + id: 'ongoing', + scheduleTime: now.add(const Duration(minutes: 30)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.ongoing); - expect(bloc.state.schedule?.id, 'ongoing'); - }); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule?.id, 'ongoing'); + expect(startUseCase.calls, isEmpty); + expect(navigationService.pushedRoutes, isEmpty); + }, + ); test( 'at preparation start boundary it transitions to started and navigates once', @@ -739,7 +955,8 @@ void main() { moveTime: Duration.zero, scheduleSpareTime: Duration.zero, ); - bloc.add(ScheduleUpcomingReceived(schedule)); + await followQueryWithLiveClock(); + controller.add(schedule); await Future.delayed(Duration.zero); expect(bloc.state.status, ScheduleStatus.upcoming); @@ -752,7 +969,7 @@ void main() { ); test( - 'when received exactly at preparationStartTime it starts and navigates once', + 'reading exactly preparationStartTime does not create a start intent', () async { final schedule = buildSchedule( id: 'exact-boundary', @@ -772,10 +989,11 @@ void main() { await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.status, ScheduleStatus.upcoming); expect(bloc.state.schedule?.id, 'exact-boundary'); expect(bloc.state.isEarlyStarted, isFalse); - expect(navigationService.pushedRoutes, ['/scheduleStart']); + expect(navigationService.pushedRoutes, isEmpty); + expect(startUseCase.calls, isEmpty); }, ); @@ -811,7 +1029,7 @@ void main() { ); test( - 'late entry fast-forwards elapsed preparation to current step', + 'restoring a durable run derives elapsed preparation to current step', () async { final schedule = buildSchedule( id: 'late-entry', @@ -833,11 +1051,11 @@ void main() { ); now = schedule.preparationStartTime.add(const Duration(minutes: 15)); - bloc.add(ScheduleUpcomingReceived(schedule)); + receiveDurableRun(schedule); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.ongoing); + expect(bloc.state.status, ScheduleStatus.started); expect(bloc.state.schedule!.preparation.currentStep?.id, 's2'); expect( bloc.state.schedule!.preparation.preparationStepList[1].elapsedTime, @@ -848,7 +1066,7 @@ void main() { ); test( - 'entering ongoing and legacy refresh derive elapsed from wall clock', + 'restoring durable active run and legacy refresh derive elapsed from wall clock', () async { final schedule = buildSchedule( id: 'tick', @@ -864,8 +1082,8 @@ void main() { ); now = schedule.preparationStartTime.add(const Duration(seconds: 2)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); await Future.delayed(Duration.zero); final caughtUpElapsed = bloc.state.schedule!.preparation.elapsedTime; @@ -917,8 +1135,8 @@ void main() { ], ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); await Future.delayed(Duration.zero); final restoredSteps = @@ -1019,8 +1237,8 @@ void main() { ], ); now = startedAt.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); await Future.delayed(Duration.zero); expect( bloc.state.schedule!.preparation.elapsedTime, @@ -1060,8 +1278,8 @@ void main() { ], ); now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); bloc.add(const ScheduleStepSkipped()); await Future.delayed(Duration.zero); @@ -1093,8 +1311,8 @@ void main() { ], ); now = startedAt.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); await Future.delayed(Duration.zero); saveUseCase.calls.clear(); @@ -1122,8 +1340,8 @@ void main() { ], ); now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); bloc.add(const ScheduleStepSkipped()); await Future.delayed(Duration.zero); @@ -1147,8 +1365,8 @@ void main() { ], ); now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); final before = bloc.state.schedule!.preparation; bloc.add(const ScheduleStepSkipped()); @@ -1170,8 +1388,8 @@ void main() { ], ); now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); bloc.add(const ScheduleFinished(7)); final finished = await bloc.stream.firstWhere( @@ -1203,8 +1421,8 @@ void main() { ], ); now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); expect(notifiedStepIds, isEmpty); bloc.add(const ScheduleTick(Duration(minutes: 10))); @@ -1249,9 +1467,10 @@ void main() { scheduleSpareTime: Duration.zero, ); - bloc.add(ScheduleUpcomingReceived(scheduleA)); + await followQueryWithLiveClock(); + controller.add(scheduleA); await Future.delayed(const Duration(milliseconds: 20)); - bloc.add(ScheduleUpcomingReceived(scheduleB)); + controller.add(scheduleB); await Future.delayed(const Duration(milliseconds: 220)); expect(bloc.state.schedule?.id, 'B'); @@ -1832,14 +2051,14 @@ void main() { ); finishUseCase.throwOnCall = true; now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + receiveDurableRun(schedule); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.started); bloc.add(const ScheduleFinished(11)); await Future.delayed(Duration.zero); expect(finishUseCase.calls, [('finish-fails', 11)]); - expect(bloc.state.status, ScheduleStatus.ongoing); + expect(bloc.state.status, ScheduleStatus.started); expect(clearTimedUseCase.calls, isNot(contains('finish-fails'))); }); }); @@ -1870,7 +2089,7 @@ void main() { startPreparation: true, ), ); - expect(ScheduleUpcomingReceived(schedule).props, [schedule]); + expect(ScheduleUpcomingReceived(schedule).props, [schedule, null]); expect( const ScheduleAlarmPromptRequested( scheduleId: 's1', diff --git a/test/presentation/backup/backup_processing_panel_test.dart b/test/presentation/backup/backup_processing_panel_test.dart new file mode 100644 index 00000000..7a3874bc --- /dev/null +++ b/test/presentation/backup/backup_processing_panel_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/presentation/backup/backup_processing_panel.dart'; + +void main() { + for (final locale in ['ko', 'en']) { + testWidgets( + '$locale at 200% exposes cancellation and retry without losing running ownership', + (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.binding.setSurfaceSize(const Size(360, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final owner = BackupProcessingOwner(); + final lease = owner.acquire()..beginOperation(); + await tester.pumpWidget( + MaterialApp( + locale: Locale(locale), + supportedLocales: const [Locale('ko'), Locale('en')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold( + body: SingleChildScrollView( + child: BackupProcessingPanel(owner: owner), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.text(locale == 'ko' ? '백업 작업 취소' : 'Cancel backup operation'), + ); + await tester.pump(); + expect( + find.textContaining( + locale == 'ko' ? '시스템 파일 창' : 'system file window', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + expect(owner.active, same(lease)); + var cleaned = false; + lease.retainCleanup(() async { + cleaned = true; + }); + await tester.pump(); + await tester.tap( + find.text( + locale == 'ko' + ? '같은 작업 정리 다시 시도' + : 'Retry this operation’s cleanup', + ), + ); + await tester.pump(); + expect(cleaned, false); + expect(owner.active, same(lease)); + lease.endOperation(); + await tester.pumpAndSettle(); + expect(cleaned, true); + expect(owner.active, isNull); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + } +} diff --git a/test/presentation/backup/backup_recurring_time_review_screen_test.dart b/test/presentation/backup/backup_recurring_time_review_screen_test.dart new file mode 100644 index 00000000..91c92058 --- /dev/null +++ b/test/presentation/backup/backup_recurring_time_review_screen_test.dart @@ -0,0 +1,585 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_recurring_time_review.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/backup/backup_recurring_time_review_screen.dart'; + +final start = DateTime.utc(2031, 1, 1, 9); +final sourceRule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + count: 3, +); +final sourceIssue = BackupTimeReviewIssue( + id: 'rule-1', + kind: BackupTimeFieldKind.recurrenceRule, + reason: BackupTimeIssueReason.recurrenceOrder, + fieldPath: 'recurring.segments[0].ruleJson', + name: 'Morning', + originalLiteral: 'opaque source', + currentLiteral: 'opaque source', + civil: CivilDateTime.fromFields(start), + zone: 'UTC', +); +ScheduleEntity schedule(String id) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Home'), + scheduleName: 'Appointment $id', + scheduleTime: start, + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); +final slot = RecurrenceSlot( + civilTime: start, + instantUtc: start, + offsetSeconds: 0, + ordinal: 1, +); +const tombstone = TimeCorrectionExclusion( + 'old-segment', + '2031-01-02T09:00:00.000', + 2, +); +const tombstoneKey = 'old-segment\n2031-01-02T09:00:00.000'; + +class Candidate extends BackupTimeReviewInput + implements BackupRecurringTimeReviewPort { + Candidate(this.identity); + final String identity; + int disposals = 0, validations = 0, revision = 0; + final drafts = []; + final choices = []; + bool wholeRequired = false, + impacts = false, + knownConflict = false, + persistent = false, + unresolved = false, + empty = false; + int possibleCount = 0; + Completer? pendingSeed; + Completer? pendingPlan; + Completer? pendingChoice; + @override + BackupTimeReviewSummary get summary => BackupTimeReviewSummary( + identity: identity, + revision: revision, + validationNowUtc: DateTime.utc(2030), + rulesIdentity: 'rules-v1', + issueCount: 1, + independentStructureChecked: true, + ); + @override + Future recurrenceRule(String issueId) async { + expect(issueId, sourceIssue.id); + return pendingSeed?.future ?? sourceRule; + } + + BackupRecurringTimePlan plan(BackupRecurringTimeDraft draft) => + BackupRecurringTimePlan( + draft: draft, + issue: sourceIssue, + anchor: BackupRecurringTimeAnchor( + segmentId: 'old-segment', + originalSlot: start, + scheduleId: empty ? null : 'anchor', + originalOrdinal: empty ? null : 1, + ), + originalRule: sourceRule, + originalSchedule: schedule('anchor'), + originalFrom: start, + originalBefore: null, + closeAt: start, + mapping: RecurringTimeCorrectionMapping( + rule: draft.rule, + automaticallyRetainedCount: false, + rows: impacts + ? [TimeCorrectionRowMapping(schedule('detached'), null)] + : [], + protectedRows: [], + exclusions: impacts + ? [const TimeCorrectionExclusionMapping(tombstone, null)] + : [], + protectedSlots: [], + workUnits: 1, + firstSlot: slot, + ), + conflicts: TimeCorrectionConflictProof( + conflicts: + knownConflict && !draft.excludedConflictSlots.contains(slot.key) + ? [ + TimeCorrectionConflict( + slot: slot, + other: TimeCorrectionBusyInterval( + schedule('other'), + start.subtract(const Duration(minutes: 30)), + start, + ), + persistent: persistent, + ), + ] + : [], + possibleOverlaps: [ + for (var i = 0; i < possibleCount; i++) + TimeCorrectionPossibleOverlap( + id: 'possible-$i', + name: 'Uncertain appointment $i', + reason: 'unknown zone', + originalCivil: start, + timeZoneId: 'Removed/Zone', + ), + ], + through: start.add(const Duration(days: 3)), + workUnits: 1, + earliestPreparationUtc: start, + ), + unresolvedOverrides: unresolved + ? {'override': ScheduleTimeResolutionStatus.unknownZone} + : {}, + ); + @override + Future reviewRecurrence( + BackupRecurringTimeDraft draft, + ) async { + drafts.add(draft); + if (wholeRequired && !draft.replaceWholeSourceInterval) { + throw const BackupRecurringWholeReplacementRequired(); + } + return pendingPlan?.future ?? plan(draft); + } + + @override + Future chooseRecurrence(BackupRecurringTimeChoice choice) async { + choices.add(choice); + if (pendingChoice != null) await pendingChoice!.future; + revision++; + } + + @override + Future dispose() async { + disposals++; + } + + @override + Future revalidate() async { + validations++; + return this; + } + + @override + Future issues({String? cursor}) async => + BackupTimeReviewIssuePage([sourceIssue]); + @override + Future review(BackupTimeDraft draft) => + throw StateError('scalar review not expected'); + @override + Future choose(BackupTimeChoice choice) => + throw StateError('scalar choice not expected'); +} + +class Host { + final navigator = GlobalKey(); + late ValueNotifier current; + bool returned = false; + Future open( + WidgetTester tester, + Candidate candidate, { + Locale locale = const Locale('en'), + double scale = 1, + }) async { + current = ValueNotifier(candidate); + addTearDown(current.dispose); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: const Scaffold(body: Text('Parent')), + ), + ); + navigator.currentState! + .push( + MaterialPageRoute( + builder: (_) => ValueListenableBuilder( + valueListenable: current, + builder: (_, input, _) => BackupRecurringTimeReviewScreen( + input: input, + issue: sourceIssue, + ), + ), + ), + ) + .then((_) => returned = true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + } +} + +Future reveal(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + if (finder.evaluate().isEmpty) { + // A previous plan can scroll the earlier review button out of the lazy + // viewport. Search from the beginning rather than past the bottom. + tester + .state(find.byType(Scrollable).first) + .position + .jumpTo(0); + await tester.pump(); + } + await tester.scrollUntilVisible( + finder, + 220, + scrollable: find.byType(Scrollable).first, + maxScrolls: 80, + ); + await Scrollable.ensureVisible(tester.element(finder), alignment: 0.5); + await tester.pump(); +} + +Future tap(WidgetTester tester, String key) async { + await reveal(tester, key); + await tester.tap(find.byKey(Key(key))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +Future expectChoose(WidgetTester tester, bool enabled) async { + await reveal(tester, 'choose-backup-recurrence'); + expect( + tester + .widget(find.byKey(const Key('choose-backup-recurrence'))) + .onPressed, + enabled ? isNotNull : isNull, + ); +} + +Future confirm(WidgetTester tester) async { + await tap(tester, 'choose-backup-recurrence'); + await tester.tap(find.byKey(const Key('confirm-backup-recurrence-choice'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +void main() { + setUp(() { + WidgetController.hitTestWarningShouldBeFatal = true; + }); + tearDown(() { + WidgetController.hitTestWarningShouldBeFatal = false; + }); + + testWidgets( + 'read and cancel borrow ownership without staging or full validation', + (tester) async { + final input = Candidate('one'); + final host = Host(); + await host.open(tester, input); + expect(input.drafts, isEmpty); + expect(input.choices, isEmpty); + await tap(tester, 'cancel-backup-recurrence'); + expect(host.returned, isTrue); + expect(input.disposals, 0); + expect(input.validations, 0); + }, + ); + testWidgets('preview and canceled confirmation never change candidate', ( + tester, + ) async { + final input = Candidate('one'); + final host = Host(); + await host.open(tester, input); + await tap(tester, 'review-backup-recurrence'); + expect(input.drafts.single.endExplicitlyChosen, isFalse); + expect(input.drafts.single.rule.repeatedTime, isNull); + expect(input.choices, isEmpty); + await tap(tester, 'choose-backup-recurrence'); + host.navigator.currentState!.pop(false); + await tester.pumpAndSettle(); + expect(input.choices, isEmpty); + expect(host.returned, isFalse); + expect(input.disposals, 0); + }); + testWidgets( + 'explicit confirm changes candidate only and returns borrowed owner', + (tester) async { + final input = Candidate('one'); + final host = Host(); + await host.open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await confirm(tester); + expect(input.choices, hasLength(1)); + expect(input.choices.single.plan.draft.identity, 'one'); + expect(host.returned, isTrue); + expect(input.disposals, 0); + expect(input.validations, 0); + }, + ); + testWidgets( + 'whole source request and actual impact acknowledgement are separate explicit choices', + (tester) async { + final input = Candidate('one')..wholeRequired = true; + final host = Host(); + await host.open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await reveal(tester, 'request-backup-recurrence-whole'); + expect( + tester + .widget( + find.byKey(const Key('request-backup-recurrence-whole')), + ) + .value, + isFalse, + ); + expect(input.choices, isEmpty); + await tap(tester, 'request-backup-recurrence-whole'); + await tap(tester, 'review-backup-recurrence'); + expect(input.drafts.last.replaceWholeSourceInterval, isTrue); + await expectChoose(tester, false); + await tap(tester, 'confirm-backup-recurrence-whole'); + await expectChoose(tester, true); + await confirm(tester); + expect(input.choices.single.confirmedWholeSourceReplacement, isTrue); + }, + ); + testWidgets( + 'standalone, unmatched deletion and uncertainty require exact independent acknowledgement', + (tester) async { + final input = Candidate('one') + ..impacts = true + ..possibleCount = 2; + await Host().open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await expectChoose(tester, false); + await tap(tester, 'detached-detached'); + await expectChoose(tester, false); + await tap(tester, 'exclusion-$tombstoneKey'); + await expectChoose(tester, false); + await tap(tester, 'ack-backup-recurrence-possible'); + await expectChoose(tester, true); + await tap(tester, 'detached-detached'); + await expectChoose(tester, false); + await tap(tester, 'detached-detached'); + await confirm(tester); + final choice = input.choices.single; + expect(choice.confirmedDetachedIds, {'detached'}); + expect(choice.confirmedUnmatchedExclusions, {tombstoneKey}); + expect(choice.acknowledgedPossibleIds, {'possible-0', 'possible-1'}); + }, + ); + testWidgets('new plan resets all previous acknowledgements', (tester) async { + final input = Candidate('one') + ..impacts = true + ..possibleCount = 1; + await Host().open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await tap(tester, 'detached-detached'); + await tap(tester, 'exclusion-$tombstoneKey'); + await tap(tester, 'ack-backup-recurrence-possible'); + await expectChoose(tester, true); + await tap(tester, 'review-backup-recurrence'); + await expectChoose(tester, false); + for (final key in [ + 'detached-detached', + 'exclusion-$tombstoneKey', + 'ack-backup-recurrence-possible', + ]) { + await reveal(tester, key); + expect( + tester.widget(find.byKey(Key(key))).value, + isFalse, + ); + } + expect(input.choices, isEmpty); + }); + testWidgets('finite conflict exclusion creates a fresh complete plan', ( + tester, + ) async { + final input = Candidate('one') + ..knownConflict = true + ..possibleCount = 1; + await Host().open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await tap(tester, 'ack-backup-recurrence-possible'); + await expectChoose(tester, false); + await tap(tester, 'exclude-backup-slot-${slot.key}'); + expect(input.drafts, hasLength(2)); + expect(input.drafts.last.excludedConflictSlots, {slot.key}); + await expectChoose(tester, false); + await tap(tester, 'ack-backup-recurrence-possible'); + await expectChoose(tester, true); + expect(input.choices, isEmpty); + }); + testWidgets( + 'persistent conflict cannot be excluded and unresolved override returns parent without disposal', + (tester) async { + final input = Candidate('one') + ..knownConflict = true + ..persistent = true + ..unresolved = true; + final host = Host(); + await host.open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await expectChoose(tester, false); + expect(find.byKey(Key('exclude-backup-slot-${slot.key}')), findsNothing); + await tap(tester, 'resolve-override'); + expect(host.returned, isTrue); + expect(input.disposals, 0); + expect(input.choices, isEmpty); + }, + ); + testWidgets( + 'zero materialized anchor reports zero without inventing an ordinal', + (tester) async { + final input = Candidate('one')..empty = true; + await Host().open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await reveal(tester, 'choose-backup-recurrence'); + expect(find.textContaining('0 existing appointments'), findsOneWidget); + expect(find.textContaining('#null'), findsNothing); + expect(input.drafts.single.endExplicitlyChosen, isFalse); + }, + ); + testWidgets('late source read cannot replace a different candidate', ( + tester, + ) async { + final old = Candidate('old')..pendingSeed = Completer(); + final next = Candidate('next'); + final host = Host(); + await host.open(tester, old); + host.current.value = next; + await tester.pump(); + await tester.pump(); + old.pendingSeed!.complete(sourceRule.withTimeZone('Removed/Zone')); + await tester.pump(); + await tap(tester, 'review-backup-recurrence'); + expect(next.drafts.single.rule.timeZoneId, 'UTC'); + expect(old.drafts, isEmpty); + }); + testWidgets( + 'late review plan cannot enable replacement candidate confirmation', + (tester) async { + final old = Candidate('old')..pendingPlan = Completer(); + final next = Candidate('next'); + final host = Host(); + await host.open(tester, old); + await tap(tester, 'review-backup-recurrence'); + host.current.value = next; + await tester.pump(); + await tester.pump(); + old.pendingPlan!.complete(old.plan(old.drafts.single)); + await tester.pump(); + expect(find.byKey(const Key('choose-backup-recurrence')), findsNothing); + expect(old.choices, isEmpty); + expect(next.choices, isEmpty); + }, + ); + testWidgets( + 'confirmation opened by retired owner cannot submit to either candidate', + (tester) async { + final old = Candidate('old'); + final next = Candidate('next'); + final host = Host(); + await host.open(tester, old); + await tap(tester, 'review-backup-recurrence'); + await tap(tester, 'choose-backup-recurrence'); + host.current.value = next; + await tester.pump(); + await tester.pump(); + await tester.tap( + find.byKey(const Key('confirm-backup-recurrence-choice')), + ); + await tester.pumpAndSettle(); + expect(old.choices, isEmpty); + expect(next.choices, isEmpty); + expect(host.returned, isFalse); + }, + ); + testWidgets( + 'in-flight choice blocks back; its late completion cannot pop replacement', + (tester) async { + final old = Candidate('old')..pendingChoice = Completer(); + final next = Candidate('next'); + final host = Host(); + await host.open(tester, old); + await tap(tester, 'review-backup-recurrence'); + await confirm(tester); + await host.navigator.currentState!.maybePop(); + await tester.pump(); + expect(host.returned, isFalse); + host.current.value = next; + await tester.pump(); + await tester.pump(); + old.pendingChoice!.complete(); + await tester.pumpAndSettle(); + expect(host.returned, isFalse); + expect(old.choices, hasLength(1)); + expect(next.choices, isEmpty); + expect(old.disposals, 0); + }, + ); + for (final locale in ['ko', 'en']) { + testWidgets( + '$locale large text small screen can review and confirm without overflow', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final input = Candidate('one'); + final host = Host(); + await host.open(tester, input, locale: Locale(locale), scale: 2.5); + await tap(tester, 'review-backup-recurrence'); + await tap(tester, 'choose-backup-recurrence'); + final button = find.byKey( + const Key('confirm-backup-recurrence-choice'), + ); + await Scrollable.ensureVisible(tester.element(button), alignment: 0.5); + await tester.pump(); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(host.returned, isTrue); + expect(input.choices, hasLength(1)); + }, + ); + } + testWidgets( + 'uncertain impact pagination retains exact acknowledgement for all records', + (tester) async { + final input = Candidate('one')..possibleCount = 11; + await Host().open(tester, input); + await tap(tester, 'review-backup-recurrence'); + await reveal(tester, 'backup-recurrence-possible-next'); + expect(find.textContaining('Uncertain appointment 10\n'), findsNothing); + await tap(tester, 'backup-recurrence-possible-next'); + expect(find.textContaining('Uncertain appointment 10\n'), findsOneWidget); + await tap(tester, 'ack-backup-recurrence-possible'); + await confirm(tester); + expect(input.choices.single.acknowledgedPossibleIds, { + for (var i = 0; i < 11; i++) 'possible-$i', + }); + }, + ); +} diff --git a/test/presentation/backup/backup_time_review_screen_test.dart b/test/presentation/backup/backup_time_review_screen_test.dart new file mode 100644 index 00000000..7ad1d3bb --- /dev/null +++ b/test/presentation/backup/backup_time_review_screen_test.dart @@ -0,0 +1,709 @@ +import 'dart:async'; +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/civil_time_occurrence.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/backup/backup_time_review_screen.dart'; + +BackupTimeReviewIssue issue(String id, {bool recurrence = false}) => + BackupTimeReviewIssue( + id: id, + kind: recurrence + ? BackupTimeFieldKind.recurrenceRule + : BackupTimeFieldKind.scheduleOccurrence, + reason: recurrence + ? BackupTimeIssueReason.recurrenceOrder + : BackupTimeIssueReason.changedOffset, + fieldPath: 'schedules[0].scheduleTime', + name: 'Appointment $id', + originalLiteral: '2030-01-01T09:00:00.123456', + currentLiteral: '2030-01-01T09:00:00.123456', + civil: CivilDateTime.parse('2030-01-01T09:00:00.123456'), + zone: 'UTC', + ); + +class ReviewInput extends BackupTimeReviewInput { + ReviewInput({List? items}) + : items = items ?? [issue('one')]; + final List items; + final cursors = []; + final drafts = []; + final choices = []; + int disposeCalls = 0, validateCalls = 0, failuresRemaining = 0; + bool transferred = false, disposed = false, gap = false; + Completer? pendingPage; + Completer? pendingValidation; + ReadyInput? ready; + Map? proofs; + bool omitProof = false; + List offsets = [0]; + @override + BackupTimeReviewSummary get summary => BackupTimeReviewSummary( + identity: 'owner', + revision: choices.length, + validationNowUtc: DateTime.utc(2026), + rulesIdentity: 'rules', + issueCount: items.length, + independentStructureChecked: true, + ); + @override + Future issues({String? cursor}) async { + cursors.add(cursor); + if (pendingPage != null) return pendingPage!.future; + return BackupTimeReviewIssuePage([ + cursor == null ? items.first : items.last, + ], nextCursor: cursor == null && items.length > 1 ? 'page-2' : null); + } + + @override + Future review(BackupTimeDraft draft) async { + drafts.add(draft); + return BackupTimeFieldReview( + draft: draft, + issue: items.firstWhere((i) => i.id == draft.issueId), + choices: gap + ? [] + : [ + for (final offset in offsets) + CivilTimeOccurrence( + offsetSeconds: offset, + instantUtc: draft.civil.atOffset(offset), + ), + ], + conflictsByOffset: omitProof + ? {} + : { + for (final offset in offsets) + offset: + proofs?[offset] ?? + TimeCorrectionConflictProof( + conflicts: [], + through: draft.civil.toUtcCarrier(), + workUnits: 1, + ), + }, + nextValidCivil: gap + ? CivilDateTime.parse('2030-01-01T10:00:00.123456') + : null, + ); + } + + @override + Future choose(BackupTimeChoice choice) async { + choices.add(choice); + } + + @override + Future revalidate() async { + validateCalls++; + if (pendingValidation != null) return pendingValidation!.future; + if (ready != null) { + transferred = true; + return ready!; + } + return this; + } + + @override + Future dispose() async { + disposeCalls++; + if (transferred || disposed) return; + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('private staging path'); + } + disposed = true; + } +} + +class ReadyInput extends BackupRestoreInput { + bool disposed = false; + @override + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: DateTime.utc(2026), + sourceAppVersion: 'test', + sourcePlatform: 'test', + scheduleCount: 1, + templateCount: 0, + defaultPreparationStepCount: 0, + ); + @override + Future dispose() async { + disposed = true; + } +} + +class RouteHost { + final navigator = GlobalKey(); + BackupRestoreInput? result; + bool completed = false; + Future open( + WidgetTester tester, + ReviewInput input, { + Locale locale = const Locale('en'), + double scale = 1, + }) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: const Scaffold(body: Text('Home')), + ), + ); + navigator.currentState! + .push( + MaterialPageRoute( + builder: (_) => BackupTimeReviewScreen(input: input), + ), + ) + .then((value) { + result = value; + completed = true; + }); + await tester.pumpAndSettle(); + } +} + +Future tap(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.scrollUntilVisible( + finder, + 200, + scrollable: find.byType(Scrollable).first, + maxScrolls: 40, + ); + await Scrollable.ensureVisible(tester.element(finder), alignment: 0.5); + await tester.pump(); + await tester.tap(finder); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +Future chooseOffset(WidgetTester tester) async { + await tap(tester, 'backup-time-issue-one'); + await tap(tester, 'review-backup-time-field'); + expect( + tester + .widget(find.byKey(const Key('choose-backup-time-field'))) + .onPressed, + isNull, + ); + await tap(tester, 'backup-time-offset-0'); + await tap(tester, 'choose-backup-time-field'); +} + +TimeCorrectionConflictProof possibleProof(String id) => + TimeCorrectionConflictProof( + conflicts: [], + through: DateTime.utc(2030, 1, 1, 9), + workUnits: 1, + possibleOverlaps: [ + TimeCorrectionPossibleOverlap( + id: id, + name: 'Uncertain $id', + reason: 'unknownZone', + originalCivil: DateTime.utc(2030, 1, 1, 9), + timeZoneId: 'Old/Unavailable', + ), + ], + ); + +TimeCorrectionConflictProof knownProof() { + final when = DateTime.utc(2030, 1, 1, 9); + final schedule = ScheduleEntity( + id: 'other', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Other meeting', + scheduleNote: '', + scheduleTime: when, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: false, + ); + return TimeCorrectionConflictProof( + conflicts: [ + TimeCorrectionConflict( + slot: RecurrenceSlot( + civilTime: when, + instantUtc: when, + offsetSeconds: 0, + ordinal: 1, + ), + other: TimeCorrectionBusyInterval( + schedule, + when.subtract(const Duration(minutes: 30)), + when, + ), + ), + ], + through: when, + workUnits: 1, + ); +} + +Future reviewOffset(WidgetTester tester) async { + await tap(tester, 'backup-time-issue-one'); + await tap(tester, 'review-backup-time-field'); + await tap(tester, 'backup-time-offset-0'); +} + +void main() { + setUp(() { + WidgetController.hitTestWarningShouldBeFatal = true; + }); + tearDown(() { + WidgetController.hitTestWarningShouldBeFatal = false; + }); + testWidgets('a queued ready navigation cannot pop a replacement owner', ( + tester, + ) async { + final pending = Completer(); + final old = ReviewInput()..pendingValidation = pending; + final replacement = ReviewInput(items: [issue('new')]); + final selected = ValueNotifier(old); + addTearDown(selected.dispose); + final navigator = GlobalKey(); + var completed = false; + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: Text('Home')), + ), + ); + navigator.currentState! + .push( + MaterialPageRoute( + builder: (_) => ValueListenableBuilder( + valueListenable: selected, + builder: (_, input, _) => BackupTimeReviewScreen(input: input), + ), + ), + ) + .then((_) => completed = true); + await tester.pumpAndSettle(); + await tap(tester, 'revalidate-backup-time'); + final ready = ReadyInput(); + old.transferred = true; + pending.complete(ready); + await tester.idle(); + selected.value = replacement; + await tester.pumpAndSettle(); + expect(completed, isFalse); + expect(ready.disposed, isTrue); + expect(find.byKey(const Key('backup-time-issue-new')), findsOneWidget); + expect(replacement.disposed, isFalse); + }); + testWidgets( + 'legacy instant keeps civil fields read only and does not require an appointment conflict proof', + (tester) async { + final original = issue('one'); + final input = ReviewInput( + items: [ + BackupTimeReviewIssue( + id: 'one', + kind: BackupTimeFieldKind.templateCreatedAt, + reason: BackupTimeIssueReason.missingInstantOffset, + fieldPath: 'templates[0].createdAt', + name: 'Template created', + originalLiteral: original.originalLiteral, + currentLiteral: original.currentLiteral, + civil: original.civil, + zone: 'UTC', + ), + ], + )..omitProof = true; + await RouteHost().open(tester, input); + await tap(tester, 'backup-time-issue-one'); + expect(find.byKey(const Key('backup-time-date')), findsNothing); + expect(find.byKey(const Key('backup-time-clock')), findsNothing); + await tap(tester, 'review-backup-time-field'); + await tap(tester, 'backup-time-offset-0'); + await tap(tester, 'choose-backup-time-field'); + await tester.tap(find.byKey(const Key('confirm-backup-time-choice'))); + await tester.pumpAndSettle(); + expect(input.choices.single.review.draft.civil, original.civil); + expect(input.choices.single.acknowledgedPossibleIds, isEmpty); + expect(input.validateCalls, 1); + }, + ); + + testWidgets('missing overlap proof never enables schedule selection', ( + tester, + ) async { + final input = ReviewInput()..omitProof = true; + await RouteHost().open(tester, input); + await reviewOffset(tester); + expect(find.byKey(const Key('backup-time-missing-proof')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('choose-backup-time-field')), + ) + .onPressed, + isNull, + ); + expect(input.choices, isEmpty); + }); + + testWidgets('known conflict shows affected appointment and blocks choice', ( + tester, + ) async { + final input = ReviewInput()..proofs = {0: knownProof()}; + await RouteHost().open(tester, input); + await reviewOffset(tester); + expect(find.textContaining('Other meeting'), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('choose-backup-time-field')), + ) + .onPressed, + isNull, + ); + expect(input.choices, isEmpty); + expect(input.validateCalls, 0); + }); + + testWidgets( + 'possible overlap requires an unchecked acknowledgement and sends exact reviewed IDs', + (tester) async { + final input = ReviewInput()..proofs = {0: possibleProof('uncertain-a')}; + await RouteHost().open(tester, input); + await reviewOffset(tester); + expect( + tester + .widget( + find.byKey(const Key('backup-time-possible-ack')), + ) + .value, + isFalse, + ); + await tester.scrollUntilVisible( + find.byKey(const Key('choose-backup-time-field')), + 150, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const Key('choose-backup-time-field')), + ) + .onPressed, + isNull, + ); + await tap(tester, 'backup-time-possible-ack'); + await tap(tester, 'choose-backup-time-field'); + expect(input.choices, isEmpty); + await tester.tap(find.byKey(const Key('confirm-backup-time-choice'))); + await tester.pumpAndSettle(); + expect(input.choices.single.acknowledgedPossibleIds, {'uncertain-a'}); + expect(input.validateCalls, 1); + expect(find.byType(BackupTimeReviewScreen), findsOneWidget); + }, + ); + + testWidgets( + 'changing occurrence clears acknowledgement and uses only its own overlap proof', + (tester) async { + final input = ReviewInput() + ..offsets = [0, 3600] + ..proofs = {0: possibleProof('first'), 3600: possibleProof('second')}; + await RouteHost().open(tester, input); + await reviewOffset(tester); + await tap(tester, 'backup-time-possible-ack'); + await tap(tester, 'backup-time-offset-3600'); + expect( + tester + .widget( + find.byKey(const Key('backup-time-possible-ack')), + ) + .value, + isFalse, + ); + expect( + tester + .widget( + find.byKey(const Key('choose-backup-time-field')), + ) + .onPressed, + isNull, + ); + expect(find.textContaining('Uncertain second'), findsOneWidget); + expect(find.textContaining('Uncertain first'), findsNothing); + await tap(tester, 'backup-time-possible-ack'); + await tap(tester, 'choose-backup-time-field'); + await tester.tap(find.byKey(const Key('confirm-backup-time-choice'))); + await tester.pumpAndSettle(); + expect(input.choices.single.acknowledgedPossibleIds, {'second'}); + expect(input.choices.single.offsetSeconds, 3600); + }, + ); + + testWidgets( + 'replacing an input ignores its pending page and disposes its lease', + (tester) async { + final pending = Completer(); + final old = ReviewInput()..pendingPage = pending; + final current = ReviewInput(items: [issue('new')]); + final selected = ValueNotifier(old); + addTearDown(selected.dispose); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ValueListenableBuilder( + valueListenable: selected, + builder: (_, input, _) => BackupTimeReviewScreen(input: input), + ), + ), + ); + await tester.pump(); + selected.value = current; + await tester.pumpAndSettle(); + expect(old.disposed, isTrue); + expect(find.byKey(const Key('backup-time-issue-new')), findsOneWidget); + pending.complete(BackupTimeReviewIssuePage([issue('obsolete')])); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('backup-time-issue-obsolete')), findsNothing); + expect(find.byKey(const Key('backup-time-issue-new')), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'confirmation from a replaced input cannot choose on either owner', + (tester) async { + final old = ReviewInput(); + final current = ReviewInput(items: [issue('new')]); + final selected = ValueNotifier(old); + addTearDown(selected.dispose); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ValueListenableBuilder( + valueListenable: selected, + builder: (_, input, _) => BackupTimeReviewScreen(input: input), + ), + ), + ); + await tester.pumpAndSettle(); + await chooseOffset(tester); + selected.value = current; + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-backup-time-choice'))); + await tester.pumpAndSettle(); + expect(old.disposed, isTrue); + expect(old.choices, isEmpty); + expect(current.choices, isEmpty); + expect(old.validateCalls, 0); + expect(current.validateCalls, 0); + expect(find.byKey(const Key('backup-time-issue-new')), findsOneWidget); + }, + ); + + testWidgets( + 'reading and cancelling never chooses or validates a replacement', + (tester) async { + final input = ReviewInput(); + final host = RouteHost(); + await host.open(tester, input); + expect(find.textContaining('schedules[0]'), findsNothing); + expect(input.choices, isEmpty); + expect(input.validateCalls, 0); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + expect(host.completed, isTrue); + expect(host.result, isNull); + expect(input.disposed, isTrue); + }, + ); + + testWidgets( + 'explicit occurrence and confirmation return fresh ready input without applying it', + (tester) async { + final ready = ReadyInput(); + final input = ReviewInput()..ready = ready; + final host = RouteHost(); + await host.open(tester, input); + await chooseOffset(tester); + expect(input.choices, isEmpty); + expect(input.validateCalls, 0); + await tester.tap(find.byKey(const Key('confirm-backup-time-choice'))); + await tester.pumpAndSettle(); + expect(input.choices.single.offsetSeconds, 0); + expect(input.validateCalls, 1); + expect(host.result, same(ready)); + expect(ready.disposed, isFalse); + expect( + input.choices.single.review.draft.civil.toUtcCarrier().microsecond, + 456, + ); + }, + ); + + testWidgets('back from confirmation leaves original candidate untouched', ( + tester, + ) async { + final input = ReviewInput(); + await RouteHost().open(tester, input); + await chooseOffset(tester); + await tester.tap(find.text('Back')); + await tester.pumpAndSettle(); + expect(input.choices, isEmpty); + expect(input.validateCalls, 0); + }); + + testWidgets( + 'gap suggestion only changes draft and requires another review and choice', + (tester) async { + final input = ReviewInput()..gap = true; + await RouteHost().open(tester, input); + await tap(tester, 'backup-time-issue-one'); + await tap(tester, 'review-backup-time-field'); + await tap(tester, 'backup-time-gap-proposal'); + expect(input.choices, isEmpty); + expect(input.validateCalls, 0); + expect(input.drafts.length, 1); + input.gap = false; + await tap(tester, 'review-backup-time-field'); + expect(input.drafts.last.civil.toUtcCarrier().hour, 10); + expect( + tester + .widget( + find.byKey(const Key('choose-backup-time-field')), + ) + .onPressed, + isNull, + ); + }, + ); + + testWidgets( + 'pagination requests bounded pages and restores previous cursor', + (tester) async { + final input = ReviewInput(items: [issue('one'), issue('two')]); + await RouteHost().open(tester, input); + await tap(tester, 'backup-time-next'); + expect(find.byKey(const Key('backup-time-issue-two')), findsOneWidget); + await tap(tester, 'backup-time-previous'); + expect(find.byKey(const Key('backup-time-issue-one')), findsOneWidget); + expect(input.cursors, [null, 'page-2', null]); + expect(input.choices, isEmpty); + }, + ); + + testWidgets('cleanup failure retains screen and enables cleanup retry only', ( + tester, + ) async { + final input = ReviewInput()..failuresRemaining = 1; + final host = RouteHost(); + await host.open(tester, input); + await tap(tester, 'cancel-backup-time'); + expect(host.completed, isFalse); + expect(input.disposed, isFalse); + expect(find.textContaining('private staging path'), findsNothing); + expect( + tester + .widget( + find.byKey(const Key('revalidate-backup-time')), + ) + .onPressed, + isNull, + ); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + expect(input.disposed, isTrue); + expect(host.completed, isTrue); + expect(input.disposeCalls, greaterThanOrEqualTo(2)); + }); + + testWidgets( + 'late ready result after cancellation is disposed and never returned', + (tester) async { + final pending = Completer(); + final input = ReviewInput()..pendingValidation = pending; + final host = RouteHost(); + await host.open(tester, input); + await tap(tester, 'revalidate-backup-time'); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + final ready = ReadyInput(); + pending.complete(ready); + await tester.pumpAndSettle(); + expect(ready.disposed, isTrue); + expect(host.completed, isTrue); + expect(host.result, isNull); + }, + ); + + testWidgets( + 'unavailable recurrence editor cannot silently validate the issue as resolved', + (tester) async { + final input = ReviewInput(items: [issue('one', recurrence: true)]); + await RouteHost().open(tester, input); + await tap(tester, 'backup-time-issue-one'); + await tester.ensureVisible( + find.byKey(const Key('backup-time-recurrence')), + ); + expect( + tester + .widget( + find.byKey(const Key('backup-time-recurrence')), + ) + .onPressed, + isNull, + ); + expect(input.choices, isEmpty); + expect(input.validateCalls, 0); + }, + ); + + for (final language in ['ko', 'en']) { + testWidgets('$language remains usable at narrow width and large text', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final input = ReviewInput(); + await RouteHost().open( + tester, + input, + locale: Locale(language), + scale: 2.5, + ); + await tap(tester, 'backup-time-issue-one'); + await tap(tester, 'review-backup-time-field'); + await tap(tester, 'backup-time-offset-0'); + await tap(tester, 'choose-backup-time-field'); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const Key('confirm-backup-time-choice')), + ); + expect(find.byType(AlertDialog), findsOneWidget); + }); + } +} diff --git a/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart b/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart index d53bce73..15efddc9 100644 --- a/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart +++ b/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart @@ -32,6 +32,8 @@ class StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { } class StubDeleteScheduleUseCase implements DeleteScheduleUseCase { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); StubDeleteScheduleUseCase(this.handler); final Future Function(ScheduleEntity schedule) handler; diff --git a/test/presentation/calendar/component/schedule_deletion_dialog_test.dart b/test/presentation/calendar/component/schedule_deletion_dialog_test.dart new file mode 100644 index 00000000..bff1500b --- /dev/null +++ b/test/presentation/calendar/component/schedule_deletion_dialog_test.dart @@ -0,0 +1,594 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/calendar/component/schedule_deletion_dialog.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import '../../../helpers/refresh_capture.dart'; + +// Presentation evidence only. DB, backup and file-journal proofs are separate. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadRefreshFonts); + + Future open( + WidgetTester tester, + _ScriptedDeletion service, { + String language = 'en', + double scale = 1, + List? results, + RecurringEditScope? scope, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(360, 800); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final zone = DeviceTimeZoneController(readZone: () async => 'UTC'); + addTearDown(zone.dispose); + late AppLocalizations l10n; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: Locale(language), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: DeviceTimeZoneScope(controller: zone, child: child!), + ), + home: Builder( + builder: (context) { + l10n = AppLocalizations.of(context)!; + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + final deleted = await showScheduleDeletionDialog( + context, + schedule: service.intent.snapshot.schedule, + deletions: service, + scope: scope, + ); + results?.add(deleted); + }, + child: const Text('Open history deletion'), + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open history deletion')); + await _paint(tester); + return l10n; + } + + for (final language in ['ko', 'en']) { + testWidgets( + '$language following confirmation describes edited future occurrences accurately', + (tester) async { + final service = _ScriptedDeletion(); + final future = _history.copyWith( + scheduleTime: DateTime.utc(2035, 1, 1, 9), + doneStatus: ScheduleDoneStatus.notEnded, + recurringSegmentId: 'series-segment', + recurringSlotKey: '2035-01-01T09:00', + recurringOrdinal: 0, + ); + service.intent = ScheduleDeletionIntent( + intentId: 'following-confirmation', + snapshot: ScheduleEditSnapshot( + future, + service.intent.snapshot.preparation, + service.intent.snapshot.baseline, + ), + scope: RecurringEditScope.following, + targets: service.intent.targets, + ); + final l10n = await open( + tester, + service, + language: language, + scale: 2, + scope: RecurringEditScope.following, + ); + expect(find.text(l10n.scheduleDeletionFollowing), findsOneWidget); + expect( + find.text(l10n.scheduleDeletionFollowingConsequences), + findsOneWidget, + ); + expect(find.text(l10n.scheduleDeletionConsequences), findsNothing); + expect(find.text(l10n.scheduleDeletionOnlySelected), findsNothing); + expect( + l10n.scheduleDeletionFollowingConsequences, + contains(language == 'ko' ? '개별' : 'edited'), + ); + expect(tester.takeException(), isNull); + _expectScopeVisible( + tester, + l10n.scheduleDeletionFollowing, + l10n.deleteScheduleConfirmAction, + ); + await captureRefresh(tester, 'u01-$language-following-200'); + await _readConsequencesEnd( + tester, + l10n.scheduleDeletionFollowingConsequences, + l10n.deleteScheduleConfirmAction, + ); + await captureRefresh(tester, 'u01-$language-following-end-200'); + await tester.tap(find.widgetWithText(TextButton, l10n.cancel)); + await _paint(tester); + expect(service.confirms, 0); + }, + ); + + testWidgets( + '$language confirmation remains accessible at 200% and cancel writes nothing', + (tester) async { + final semantics = tester.ensureSemantics(); + try { + final service = _ScriptedDeletion(); + final returned = []; + final l10n = await open( + tester, + service, + language: language, + scale: 2, + results: returned, + ); + expect(find.text(_history.scheduleName), findsOneWidget); + expect(find.text(l10n.scheduleDeletionOnlySelected), findsOneWidget); + expect(find.text(l10n.scheduleDeletionConsequences), findsOneWidget); + expect( + find.bySemanticsLabel(l10n.deleteScheduleConfirmAction), + findsOneWidget, + ); + expect(find.bySemanticsLabel(l10n.cancel), findsOneWidget); + expect(tester.takeException(), isNull); + _expectScopeVisible( + tester, + l10n.scheduleDeletionOnlySelected, + l10n.deleteScheduleConfirmAction, + ); + await captureRefresh(tester, 'u01-$language-confirm-200'); + await _readConsequencesEnd( + tester, + l10n.scheduleDeletionConsequences, + l10n.deleteScheduleConfirmAction, + ); + await tester.tap(find.widgetWithText(TextButton, l10n.cancel)); + await _paint(tester); + expect(returned, [false]); + expect(service.confirms, 0); + expect(service.retries, 0); + expect(find.byType(AlertDialog), findsNothing); + } finally { + semantics.dispose(); + } + }, + ); + + testWidgets( + '$language committed partial retains removal and retries only cleanup at 200%', + (tester) async { + final service = _ScriptedDeletion() + ..nextCleanup = ScheduleDeletionCleanup.pending; + final l10n = await open(tester, service, language: language, scale: 2); + await tester.tap( + find.widgetWithText(TextButton, l10n.deleteScheduleConfirmAction), + ); + await _paint(tester); + expect(find.text(l10n.scheduleDeletionRemovedTitle), findsOneWidget); + expect(find.text(l10n.scheduleDeletionPending), findsOneWidget); + expect(find.text(l10n.scheduleDeleteFailedTitle), findsNothing); + expect(find.text(l10n.scheduleDeletionComplete), findsNothing); + expect(tester.takeException(), isNull); + await captureRefresh(tester, 'u01-$language-partial-200'); + service.nextCleanup = ScheduleDeletionCleanup.complete; + await tester.tap( + find.widgetWithText(TextButton, l10n.scheduleDeletionRetryCleanup), + ); + await _paint(tester); + expect(service.prepares, 1); + expect(service.confirms, 1); + expect(service.retries, 1); + expect(find.text(l10n.scheduleDeletionComplete), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, l10n.ok)); + await _paint(tester); + }, + ); + } + + for (final status in [ + ScheduleDoneStatus.normalEnd, + ScheduleDoneStatus.notEnded, + ]) { + testWidgets( + 'past recurring $status skips following selection and prepares occurrence only', + (tester) async { + final service = _ScriptedDeletion(); + final history = _history.copyWith( + scheduleTime: DateTime.utc(2001, 1, 1, 9), + doneStatus: status, + recurringSegmentId: 'historical-segment', + recurringSlotKey: '2001-01-01T09:00', + recurringOrdinal: 0, + ); + service.intent = ScheduleDeletionIntent( + intentId: 'past-occurrence', + snapshot: ScheduleEditSnapshot( + history, + service.intent.snapshot.preparation, + service.intent.snapshot.baseline, + ), + scope: RecurringEditScope.occurrence, + targets: service.intent.targets, + ); + final l10n = await open(tester, service); + expect(service.prepares, 1); + expect(service.lastScope, RecurringEditScope.occurrence); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(l10n.scheduleDeletionOnlySelected), findsOneWidget); + expect(find.text(l10n.scheduleDeletionFollowing), findsNothing); + await tester.tap(find.widgetWithText(TextButton, l10n.cancel)); + await _paint(tester); + expect(service.confirms, 0); + }, + ); + } + + testWidgets( + 'barrier tap preserves confirmation and system back cancels without mutation', + (tester) async { + final service = _ScriptedDeletion(); + final returned = []; + await open(tester, service, results: returned); + await tester.tapAt(const Offset(4, 4)); + await _paint(tester); + expect(find.byType(AlertDialog), findsOneWidget); + expect(returned, isEmpty); + expect(service.confirms, 0); + await tester.binding.handlePopRoute(); + await _paint(tester); + expect(find.byType(AlertDialog), findsNothing); + expect(returned, [false]); + expect(service.confirms, 0); + expect(service.retries, 0); + }, + ); + + testWidgets( + 'DB failure never shows removed or cleanup retry and hides raw exception content', + (tester) async { + final service = _ScriptedDeletion() + ..confirmFailure = StateError('PRIVATE SQL DETAIL'); + final l10n = await open(tester, service); + await tester.tap( + find.widgetWithText(TextButton, l10n.deleteScheduleConfirmAction), + ); + await _paint(tester); + expect(find.text(l10n.scheduleDeleteFailedTitle), findsOneWidget); + expect(find.text(l10n.scheduleDeletionRemovedTitle), findsNothing); + expect(find.text(l10n.scheduleDeletionRetryCleanup), findsNothing); + expect(find.textContaining('PRIVATE SQL DETAIL'), findsNothing); + expect(service.confirms, 1); + expect(service.retries, 0); + expect(tester.takeException(), isNull); + await captureRefresh(tester, 'u01-en-db-failure'); + await tester.tap(find.widgetWithText(TextButton, l10n.ok)); + await _paint(tester); + }, + ); + + testWidgets( + 'protected preparation exposes guidance without a destructive action', + (tester) async { + final service = _ScriptedDeletion() + ..prepareFailure = const ScheduleDeletionRejected( + ScheduleDeletionFailure.protected, + ); + final l10n = await open(tester, service); + expect(find.text(l10n.scheduleDeletionPreparationActive), findsOneWidget); + expect(find.text(l10n.deleteScheduleConfirmAction), findsNothing); + expect(service.confirms, 0); + await tester.tap(find.widgetWithText(TextButton, l10n.ok)); + await _paint(tester); + }, + ); + + testWidgets( + 'already absent receipt does not present a new or historical deletion success', + (tester) async { + final service = _ScriptedDeletion() + ..commit = ScheduleDeletionCommit( + scheduleId: 'history', + store: 'store', + generation: 0, + removedIds: {}, + changed: false, + alreadyAbsent: true, + ); + final l10n = await open(tester, service); + await tester.tap( + find.widgetWithText(TextButton, l10n.deleteScheduleConfirmAction), + ); + await _paint(tester); + expect(find.text(l10n.scheduleDeletionAbsentTitle), findsOneWidget); + expect(find.text(l10n.scheduleDeletionAlreadyAbsent), findsOneWidget); + expect(find.text(l10n.scheduleDeletionRemovedTitle), findsNothing); + expect(find.text(l10n.scheduleDeletionComplete), findsNothing); + expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(TextButton, l10n.ok)); + await _paint(tester); + }, + ); + + testWidgets( + 'cancel during preparation ignores its later result without mutation', + (tester) async { + final service = _ScriptedDeletion() + ..preparation = Completer(); + final returned = []; + final l10n = await open(tester, service, results: returned); + try { + expect(find.byType(LinearProgressIndicator), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, l10n.cancel)); + await _paint(tester); + service.preparation!.complete(service.intent); + await _paint(tester); + expect(returned, [false]); + expect(service.confirms, 0); + expect(find.byType(AlertDialog), findsNothing); + expect(tester.takeException(), isNull); + } finally { + if (!service.preparation!.isCompleted) { + service.preparation!.complete(service.intent); + } + } + }, + ); + + testWidgets( + 'committed delayed cleanup can close without duplicate work or false completion', + (tester) async { + final service = _ScriptedDeletion() + ..completion = Completer(); + final returned = []; + final l10n = await open(tester, service, results: returned); + try { + await tester.tap( + find.widgetWithText(TextButton, l10n.deleteScheduleConfirmAction), + ); + await _paint(tester); + service.commitObserver!(service.commit); + await _paint(tester); + expect(find.text(l10n.scheduleDeletionCleaning), findsOneWidget); + final retry = tester.widget( + find.widgetWithText(TextButton, l10n.scheduleDeletionRetryCleanup), + ); + expect(retry.onPressed, isNull); + await tester.pump(const Duration(seconds: 10)); + expect(find.text(l10n.scheduleDeletionPending), findsOneWidget); + expect(find.text(l10n.scheduleDeletionComplete), findsNothing); + await tester.tap(find.widgetWithText(TextButton, l10n.ok)); + await _paint(tester); + expect(returned, [true]); + expect(find.byType(AlertDialog), findsNothing); + expect(service.completion!.isCompleted, isFalse); + expect(service.confirms, 1); + expect(service.retries, 0); + service.completion!.complete(service.result); + await _paint(tester); + expect(tester.takeException(), isNull); + } finally { + if (!service.completion!.isCompleted) { + service.completion!.complete(service.result); + } + await tester.pumpWidget(const SizedBox()); + } + }, + ); + + testWidgets('replacement generation rejects an old commit projection', ( + tester, + ) async { + final service = _ScriptedDeletion() + ..completion = Completer(); + final l10n = await open(tester, service); + try { + await tester.tap( + find.widgetWithText(TextButton, l10n.deleteScheduleConfirmAction), + ); + await _paint(tester); + service.generation++; + service.commitObserver!(service.commit); + service.completion!.complete(service.result); + await _paint(tester); + expect(find.text(l10n.scheduleDeletionRemovedTitle), findsNothing); + expect(find.text(l10n.scheduleDeletionComplete), findsNothing); + expect(service.confirms, 1); + expect(tester.takeException(), isNull); + } finally { + if (!service.completion!.isCompleted) { + service.completion!.complete(service.result); + } + // Production replacement removes the old route; explicitly unmount it here. + await tester.pumpWidget(const SizedBox()); + } + }); + + testWidgets('late preparation from replaced data cannot enable deletion', ( + tester, + ) async { + final service = _ScriptedDeletion() + ..preparation = Completer(); + final l10n = await open(tester, service); + try { + service.generation++; + service.preparation!.complete(service.intent); + await _paint(tester); + expect(find.text(l10n.deleteScheduleConfirmAction), findsNothing); + expect(find.text(_history.scheduleName), findsNothing); + expect(service.confirms, 0); + expect(tester.takeException(), isNull); + } finally { + if (!service.preparation!.isCompleted) { + service.preparation!.complete(service.intent); + } + await tester.pumpWidget(const SizedBox()); + } + }); +} + +Future _paint(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +void _expectScopeVisible( + WidgetTester tester, + String scope, + String deleteLabel, +) { + final area = tester.getRect(find.byType(AlertDialog)); + final scopeRect = tester.getRect(find.text(scope)); + final action = tester.getRect(find.widgetWithText(TextButton, deleteLabel)); + expect(scopeRect.top, greaterThanOrEqualTo(area.top)); + expect(scopeRect.bottom, lessThanOrEqualTo(action.top)); +} + +Future _readConsequencesEnd( + WidgetTester tester, + String text, + String deleteLabel, +) async { + await Scrollable.ensureVisible(tester.element(find.text(text)), alignment: 1); + await _paint(tester); + final paragraph = tester.getRect(find.text(text)); + final action = tester.getRect(find.widgetWithText(TextButton, deleteLabel)); + expect(paragraph.bottom, lessThanOrEqualTo(action.top)); + expect(paragraph.bottom, greaterThan(0)); + expect( + find.widgetWithText(TextButton, deleteLabel).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); +} + +final _history = ScheduleEntity( + id: 'history', + place: const PlaceEntity(id: 'place', placeName: 'Library'), + scheduleName: 'Archived appointment', + scheduleTime: DateTime.utc(2026, 9, 1, 9), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + isStarted: false, + isChanged: true, + doneStatus: ScheduleDoneStatus.normalEnd, +); + +class _ScriptedDeletion extends Fake implements DeleteScheduleUseCase { + int generation = 0; + int prepares = 0; + int confirms = 0; + int retries = 0; + RecurringEditScope? lastScope; + Object? prepareFailure; + Object? confirmFailure; + Completer? preparation; + Completer? completion; + void Function(ScheduleDeletionCommit)? commitObserver; + ScheduleDeletionCleanup nextCleanup = ScheduleDeletionCleanup.complete; + var intent = ScheduleDeletionIntent( + intentId: 'confirmation', + snapshot: ScheduleEditSnapshot( + _history, + const PreparationEntity(preparationStepList: []), + const ScheduleEditBaseline( + store: 'store', + generation: 0, + revision: 1, + incarnation: 'history-incarnation', + version: 4, + ), + ), + scope: RecurringEditScope.occurrence, + targets: const [ + ScheduleDeletionTarget( + id: 'history', + incarnation: 'history-incarnation', + version: 4, + ), + ], + ); + var commit = ScheduleDeletionCommit( + scheduleId: 'history', + store: 'store', + generation: 0, + removedIds: {'history'}, + changed: true, + alreadyAbsent: false, + ); + ScheduleDeletionResult get result => + ScheduleDeletionResult(commit: commit, cleanup: nextCleanup); + @override + int get currentGeneration => generation; + @override + bool isCurrentGeneration(int value) => generation == value; + @override + Future prepare( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) async { + prepares++; + lastScope = scope; + if (prepareFailure != null) { + throw prepareFailure!; + } + if (preparation != null) { + return preparation!.future; + } + return intent; + } + + @override + Future confirm( + ScheduleDeletionIntent intent, { + void Function(ScheduleDeletionCommit)? onCommitted, + }) async { + confirms++; + if (confirmFailure != null) { + throw confirmFailure!; + } + commitObserver = onCommitted; + if (completion != null) { + return completion!.future; + } + onCommitted?.call(commit); + return result; + } + + @override + Future retryCleanup( + ScheduleDeletionCommit commit, + ) async { + retries++; + return result; + } +} diff --git a/test/presentation/calendar/component/schedule_detail_test.dart b/test/presentation/calendar/component/schedule_detail_test.dart index b793f25d..68cea4e8 100644 --- a/test/presentation/calendar/component/schedule_detail_test.dart +++ b/test/presentation/calendar/component/schedule_detail_test.dart @@ -25,7 +25,8 @@ void main() { id: 'schedule-1', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Design Review', - scheduleTime: DateTime(2026, 3, 20, 9, 0), + scheduleTime: (DateTime.utc(2026, 3, 20, 9, 0)).toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 30), isChanged: false, isStarted: false, @@ -38,6 +39,7 @@ void main() { ScheduleEntity? customSchedule, Duration? preparationTime, bool isEarlyStarted = false, + bool alwaysUse24HourFormat = false, }) async { final targetSchedule = customSchedule ?? schedule; await tester.pumpWidget( @@ -45,6 +47,12 @@ void main() { bundle: _FakeSvgAssetBundle(), child: MaterialApp( locale: const Locale('en'), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(alwaysUse24HourFormat: alwaysUse24HourFormat), + child: child!, + ), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( @@ -174,36 +182,45 @@ void main() { }, ); - testWidgets('tile text updates when schedule fields change for same id', ( - tester, - ) async { - await pumpScheduleDetail(tester); - - expect(find.text('Design Review'), findsOneWidget); - expect(find.text('Office'), findsOneWidget); - expect(find.text('09:00'), findsOneWidget); - - final updatedSchedule = ScheduleEntity( - id: 'schedule-1', - place: PlaceEntity(id: 'place-1', placeName: 'New Office'), - scheduleName: 'Edited Review', - scheduleTime: DateTime(2026, 3, 20, 10, 30), - moveTime: const Duration(minutes: 45), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 20), - scheduleNote: '', - ); + for (final use24Hours in [false, true]) { + testWidgets('tile text updates for same id in EN 24h=$use24Hours', ( + tester, + ) async { + final oldTime = use24Hours ? '09:00' : '9:00 AM'; + final newTime = use24Hours ? '10:30' : '10:30 AM'; + await pumpScheduleDetail(tester, alwaysUse24HourFormat: use24Hours); + + expect(find.text('Design Review'), findsOneWidget); + expect(find.text('Office'), findsOneWidget); + expect(find.text(oldTime), findsOneWidget); + + final updatedSchedule = ScheduleEntity( + id: 'schedule-1', + place: PlaceEntity(id: 'place-1', placeName: 'New Office'), + scheduleName: 'Edited Review', + scheduleTime: (DateTime.utc(2026, 3, 20, 10, 30)).toUtc(), + occurrenceOffsetSeconds: 0, + moveTime: const Duration(minutes: 45), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 20), + scheduleNote: '', + ); - await pumpScheduleDetail(tester, customSchedule: updatedSchedule); + await pumpScheduleDetail( + tester, + customSchedule: updatedSchedule, + alwaysUse24HourFormat: use24Hours, + ); - expect(find.text('Edited Review'), findsOneWidget); - expect(find.text('New Office'), findsOneWidget); - expect(find.text('10:30'), findsOneWidget); - expect(find.text('Design Review'), findsNothing); - expect(find.text('Office'), findsNothing); - expect(find.text('09:00'), findsNothing); - }); + expect(find.text('Edited Review'), findsOneWidget); + expect(find.text('New Office'), findsOneWidget); + expect(find.text(newTime), findsOneWidget); + expect(find.text('Design Review'), findsNothing); + expect(find.text('Office'), findsNothing); + expect(find.text(oldTime), findsNothing); + }); + } testWidgets('edit action is available before preparation starts', ( tester, @@ -212,7 +229,8 @@ void main() { id: 'schedule-2', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Planning', - scheduleTime: DateTime.now().add(const Duration(hours: 3)), + scheduleTime: (DateTime.now().add(const Duration(hours: 3))).toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 30), isChanged: false, isStarted: false, @@ -238,7 +256,8 @@ void main() { id: 'schedule-3', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Planning', - scheduleTime: DateTime.now().add(const Duration(minutes: 30)), + scheduleTime: (DateTime.now().add(const Duration(minutes: 30))).toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 30), isChanged: false, isStarted: false, @@ -264,7 +283,8 @@ void main() { id: 'schedule-4', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Planning', - scheduleTime: DateTime.now().add(const Duration(hours: 3)), + scheduleTime: (DateTime.now().add(const Duration(hours: 3))).toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 30), isChanged: false, isStarted: false, @@ -284,58 +304,64 @@ void main() { expect(getActionButtonCount(tester), 1); }); - testWidgets('delete action stays available for started unfinished schedule', ( - tester, - ) async { - final startedSchedule = ScheduleEntity( - id: 'schedule-5', - place: PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: 'Planning', - scheduleTime: DateTime.now().add(const Duration(hours: 3)), - moveTime: const Duration(minutes: 30), - isChanged: false, - isStarted: true, - scheduleSpareTime: const Duration(minutes: 10), - scheduleNote: '', - doneStatus: ScheduleDoneStatus.notEnded, - startedAt: DateTime.now(), - ); + testWidgets( + 'started entry remains discoverable for preparation-block guidance', + (tester) async { + final startedSchedule = ScheduleEntity( + id: 'schedule-5', + place: PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Planning', + scheduleTime: (DateTime.now().add(const Duration(hours: 3))).toUtc(), + occurrenceOffsetSeconds: 0, + moveTime: const Duration(minutes: 30), + isChanged: false, + isStarted: true, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + doneStatus: ScheduleDoneStatus.notEnded, + startedAt: DateTime.now(), + ); - await pumpScheduleDetail( - tester, - customSchedule: startedSchedule, - preparationTime: const Duration(minutes: 20), - ); + await pumpScheduleDetail( + tester, + customSchedule: startedSchedule, + preparationTime: const Duration(minutes: 20), + ); - await openTrailingActions(tester); + await openTrailingActions(tester); - expect(getActionButtonCount(tester), 1); - }); + expect(getActionButtonCount(tester), 1); + }, + ); - testWidgets('delete action is hidden for finished schedules', (tester) async { - final finishedSchedule = ScheduleEntity( - id: 'schedule-6', - place: PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: 'Planning', - scheduleTime: DateTime.now().add(const Duration(hours: 3)), - moveTime: const Duration(minutes: 30), - isChanged: false, - isStarted: true, - scheduleSpareTime: const Duration(minutes: 10), - scheduleNote: '', - doneStatus: ScheduleDoneStatus.normalEnd, - startedAt: DateTime.now().subtract(const Duration(hours: 1)), - finishedAt: DateTime.now(), - ); + testWidgets( + 'finished history exposes individual delete while edit remains hidden', + (tester) async { + final finishedSchedule = ScheduleEntity( + id: 'schedule-6', + place: PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Planning', + scheduleTime: (DateTime.now().add(const Duration(hours: 3))).toUtc(), + occurrenceOffsetSeconds: 0, + moveTime: const Duration(minutes: 30), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + doneStatus: ScheduleDoneStatus.normalEnd, + startedAt: DateTime.now().subtract(const Duration(hours: 1)), + finishedAt: DateTime.now(), + ); - await pumpScheduleDetail( - tester, - customSchedule: finishedSchedule, - preparationTime: const Duration(minutes: 20), - ); + await pumpScheduleDetail( + tester, + customSchedule: finishedSchedule, + preparationTime: const Duration(minutes: 20), + ); - await openTrailingActions(tester); + await openTrailingActions(tester); - expect(getActionButtonCount(tester), 0); - }); + expect(getActionButtonCount(tester), 1); + }, + ); } diff --git a/test/presentation/calendar/screens/calendar_screen_test.dart b/test/presentation/calendar/screens/calendar_screen_test.dart index 63bf08c0..d1362ca5 100644 --- a/test/presentation/calendar/screens/calendar_screen_test.dart +++ b/test/presentation/calendar/screens/calendar_screen_test.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; import 'dart:async'; import 'dart:convert'; @@ -65,10 +68,51 @@ class _StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { } class _StubDeleteScheduleUseCase implements DeleteScheduleUseCase { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); _StubDeleteScheduleUseCase({this.error}); final Object? error; final deletedSchedules = []; + List schedules = []; + @override + int get currentGeneration => 0; + @override + bool isCurrentGeneration(int generation) => generation == 0; + @override + Future prepare( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) async => ScheduleDeletionIntent( + intentId: 'ui', + scope: scope, + targets: [], + snapshot: ScheduleEditSnapshot( + schedules.singleWhere((v) => v.id == id), + const PreparationEntity(preparationStepList: []), + const ScheduleEditBaseline(store: 'ui', generation: 0, revision: 0), + ), + ); + @override + Future confirm( + ScheduleDeletionIntent intent, { + void Function(ScheduleDeletionCommit)? onCommitted, + }) async { + await call(intent.snapshot.schedule); + final commit = ScheduleDeletionCommit( + scheduleId: intent.snapshot.schedule.id, + store: 'ui', + generation: 0, + removedIds: {intent.snapshot.schedule.id}, + changed: true, + alreadyAbsent: false, + ); + onCommitted?.call(commit); + return ScheduleDeletionResult( + commit: commit, + cleanup: ScheduleDeletionCleanup.complete, + ); + } @override Future call(ScheduleEntity schedule) async { @@ -135,11 +179,13 @@ void main() { }) async { final loadUseCase = loadSchedulesForMonthUseCase ?? _StubLoadSchedulesForMonthUseCase(); + final deletion = deleteScheduleUseCase ?? _StubDeleteScheduleUseCase(); + deletion.schedules = schedules; getIt.registerFactory( () => MonthlySchedulesBloc( loadUseCase, _StubGetSchedulesByDateUseCase(schedules), - deleteScheduleUseCase ?? _StubDeleteScheduleUseCase(), + deletion, _StubLoadPreparationByScheduleIdUseCase(), _StubGetPreparationByScheduleIdUseCase(), _StubStreamPreparationsUseCase(), @@ -425,7 +471,7 @@ void main() { find.textContaining('This appointment can no longer be deleted'), findsOneWidget, ); - expect(find.text('Design Review'), findsOneWidget); + expect(find.text('Design Review'), findsWidgets); expect(find.byType(TableCalendar), findsOneWidget); }); diff --git a/test/presentation/event_value_objects_test.dart b/test/presentation/event_value_objects_test.dart index 45e4895d..092e0180 100644 --- a/test/presentation/event_value_objects_test.dart +++ b/test/presentation/event_value_objects_test.dart @@ -7,16 +7,10 @@ void main() { test('default preparation edit events compare user-edited fields', () { const preparation = PreparationEntity(preparationStepList: []); - expect(const DefaultPreparationSpareTimeFormEvent().props, isEmpty); - expect(const FormEditRequested(spareTime: Duration(minutes: 7)).props, [ - const Duration(minutes: 7), - ]); + expect(const FormEditRequested().props, isEmpty); expect(const SpareTimeIncreased().props, isEmpty); expect(const SpareTimeDecreased().props, isEmpty); - expect( - const FormSubmitted(note: 'Bring bag', preparation: preparation).props, - ['Bring bag'], - ); + expect(const FormSubmitted(preparation: preparation).props, [preparation]); }); test('schedule timer events compare schedule and tick times', () { diff --git a/test/presentation/home/bloc/device_today_schedule_test.dart b/test/presentation/home/bloc/device_today_schedule_test.dart new file mode 100644 index 00000000..d1357d1a --- /dev/null +++ b/test/presentation/home/bloc/device_today_schedule_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/device_civil_day.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/presentation/home/bloc/weekly_schedules_bloc.dart'; + +ScheduleEntity appointment( + String id, + DateTime civil, + String zone, + int offset, +) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Place'), + scheduleName: id, + scheduleTime: civil, + timeZoneId: zone, + occurrenceOffsetSeconds: offset, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleNote: '', +); + +void main() { + test( + 'device today contains an instant while calendar keeps original civil date', + () { + final now = DateTime.utc(2026, 9, 24, 23, 50); + final seoul = appointment( + 'seoul', + DateTime.utc(2026, 9, 25, 9), + 'Asia/Seoul', + 32400, + ); + final state = WeeklySchedulesState(schedules: [seoul]); + final appointmentDevice = DateTime.utc(2026, 9, 25).toLocal(); + final nowDevice = now.toLocal(); + final sameDay = + appointmentDevice.year == nowDevice.year && + appointmentDevice.month == nowDevice.month && + appointmentDevice.day == nowDevice.day; + expect(state.todayScheduleAt(now)?.id, sameDay ? 'seoul' : null); + expect(state.dates.single, DateTime.utc(2026, 9, 25, 9)); + if (Platform.environment['TZ'] == 'America/New_York') { + expect(nowDevice.day, 24); + expect(appointmentDevice.day, 24); + expect(state.todayScheduleAt(now)?.id, 'seoul'); + } + }, + ); + + test('today orders different civil values by their actual instants', () { + final now = DateTime.utc(2026, 9, 25, 12); + final later = appointment( + 'later', + DateTime.utc(2026, 9, 25, 10), + 'America/New_York', + -14400, + ); + final earlier = appointment( + 'earlier', + DateTime.utc(2026, 9, 25, 22), + 'Asia/Seoul', + 32400, + ); + final state = WeeklySchedulesState(schedules: [later, earlier]); + expect(state.todayScheduleAt(now)?.id, 'earlier'); + }); + + test( + 'day boundary is inclusive at midnight and exclusive at next midnight', + () { + final day = DeviceCivilDay.at(DateTime.utc(2026, 9, 25, 12)); + expect(day.contains(day.startUtc), isTrue); + expect( + day.contains(day.endUtc.subtract(const Duration(microseconds: 1))), + isTrue, + ); + expect(day.contains(day.endUtc), isFalse); + }, + ); + + test('actual New York spring and autumn days span 23 and 25 hours', () { + final spring = DeviceCivilDay.at(DateTime.utc(2026, 3, 8, 12)); + final autumn = DeviceCivilDay.at(DateTime.utc(2026, 11, 1, 12)); + if (Platform.environment['TZ'] == 'America/New_York') { + expect(spring.startUtc, DateTime.utc(2026, 3, 8, 5)); + expect(spring.endUtc, DateTime.utc(2026, 3, 9, 4)); + expect( + spring.endUtc.difference(spring.startUtc), + const Duration(hours: 23), + ); + expect(autumn.startUtc, DateTime.utc(2026, 11, 1, 4)); + expect(autumn.endUtc, DateTime.utc(2026, 11, 2, 5)); + expect( + autumn.endUtc.difference(autumn.startUtc), + const Duration(hours: 25), + ); + } else { + expect(spring.contains(DateTime.utc(2026, 3, 8, 12)), isTrue); + expect(autumn.contains(DateTime.utc(2026, 11, 1, 12)), isTrue); + } + }); +} diff --git a/test/presentation/home/bloc/weekly_schedules_bloc_test.dart b/test/presentation/home/bloc/weekly_schedules_bloc_test.dart index 38b483b6..56492bd1 100644 --- a/test/presentation/home/bloc/weekly_schedules_bloc_test.dart +++ b/test/presentation/home/bloc/weekly_schedules_bloc_test.dart @@ -154,8 +154,8 @@ void main() { expect(loadedWeeks, [DateTime(2026, 5, 15)]); expect(getSchedulesByDateUseCase.calls.single, ( - DateTime(2026, 5, 11), - DateTime(2026, 5, 18), + DateTime.utc(2026, 5, 9), + DateTime.utc(2026, 5, 20), )); expect(state.schedules, [schedule]); }, @@ -171,7 +171,8 @@ ScheduleEntity _schedule({ id: id, place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: id, - scheduleTime: scheduleTime, + scheduleTime: scheduleTime.toUtc(), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 10), isChanged: false, isStarted: false, diff --git a/test/presentation/home/home_device_day_test.dart b/test/presentation/home/home_device_day_test.dart new file mode 100644 index 00000000..c352fc56 --- /dev/null +++ b/test/presentation/home/home_device_day_test.dart @@ -0,0 +1,504 @@ +import '../../helpers/c08_home_query_fixture.dart'; +import 'dart:async'; + +import 'package:go_router/go_router.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/device_civil_day.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; +import 'package:on_time_front/presentation/home/components/device_day_refresh.dart'; +import 'package:on_time_front/presentation/home/components/month_calendar.dart'; +import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; +import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; +import 'package:on_time_front/presentation/home/utils/home_schedule_card_selection.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:table_calendar/table_calendar.dart'; + +class _ScheduleBloc extends Fake implements ScheduleBloc { + _ScheduleBloc(this.state, {this.updates}); + final StreamController? updates; + final events = []; + @override + void add(ScheduleEvent event) => events.add(event); + @override + ScheduleState state; + @override + Stream get stream => updates?.stream ?? const Stream.empty(); +} + +ScheduleWithPreparationEntity _appointment( + DateTime instant, { + bool active = false, + bool frozenOnly = false, + bool retained = false, + ScheduleDoneStatus done = ScheduleDoneStatus.notEnded, +}) => ScheduleWithPreparationEntity( + id: 'appointment', + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Original appointment', + scheduleTime: instant.toUtc(), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: false, + isStarted: active, + startedAt: active ? instant.subtract(const Duration(minutes: 10)) : null, + preparationFrozen: active || frozenOnly, + retainedRecurringReference: retained, + doneStatus: done, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparation: const PreparationWithTimeEntity(preparationStepList: []), +); + +Widget _app(Widget child) => MaterialApp( + locale: const Locale('en'), + theme: themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), +); + +void main() { + final now = DateTime(2030, 1, 1, 12); + final day = DeviceCivilDay.at(now); + + test( + 'today classification uses the device half-open day independently of next appointment', + () { + for (final (instant, visible) in [ + (day.startUtc.subtract(const Duration(microseconds: 1)), false), + (day.startUtc, true), + (day.endUtc.subtract(const Duration(microseconds: 1)), true), + (day.endUtc, false), + ]) { + final selected = HomeScheduleCardSelection.at( + homeReadyState(_appointment(instant)), + now, + ); + expect(selected.kind == HomeScheduleCardKind.today, visible); + expect(selected.isActive, isFalse); + } + }, + ); + + test('only durable incomplete runs retain a card outside today', () { + final yesterday = day.startUtc.subtract(const Duration(hours: 2)); + final tomorrow = day.endUtc.add(const Duration(hours: 2)); + for (final instant in [yesterday, tomorrow]) { + expect( + HomeScheduleCardSelection.at( + ScheduleState.started(_appointment(instant, active: true)), + now, + ).isActive, + isTrue, + ); + expect( + HomeScheduleCardSelection.at( + ScheduleState.started(_appointment(instant, frozenOnly: true)), + now, + ).schedule, + isNull, + ); + expect( + HomeScheduleCardSelection.at( + ScheduleState.started( + _appointment( + instant, + active: true, + done: ScheduleDoneStatus.normalEnd, + ), + ), + now, + ).schedule, + isNull, + ); + } + }); + + test( + 'a closed recurring reference retains only its incomplete durable run card', + () { + for (final active in [false, true]) { + for (final done in [ + ScheduleDoneStatus.notEnded, + ScheduleDoneStatus.normalEnd, + ]) { + final selected = HomeScheduleCardSelection.at( + ScheduleState.started( + _appointment( + now.toUtc(), + active: active, + retained: true, + done: done, + ), + ), + now, + ); + expect( + selected.schedule != null, + active && done == ScheduleDoneStatus.notEnded, + ); + expect( + selected.isActive, + active && done == ScheduleDoneStatus.notEnded, + ); + } + } + }, + ); + + testWidgets( + 'actual home displays a tomorrow recommendation as Next appointment', + (tester) async { + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _app( + BlocProvider.value( + value: _ScheduleBloc(homeReadyState(_appointment(day.endUtc))), + child: HomeScreenContent( + state: const MonthlySchedulesState(), + userScore: 80, + now: now, + ), + ), + ), + ); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const Key('today_schedule_tile')), + ) + .schedule + ?.id, + 'appointment', + ); + expect(find.text('Original appointment'), findsOneWidget); + expect(find.text('Next appointment'), findsOneWidget); + expect(find.text("Today's Appointments"), findsNothing); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'actual home content labels a previous-day durable run as in progress', + (tester) async { + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _app( + BlocProvider.value( + value: _ScheduleBloc( + ScheduleState.started( + _appointment( + day.startUtc.subtract(const Duration(hours: 1)), + active: true, + ), + ), + ), + child: HomeScreenContent( + state: const MonthlySchedulesState(), + userScore: 80, + now: now, + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Preparation in progress'), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('today_schedule_tile')), + ) + .onTap, + isNotNull, + ); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'device-day observer refreshes at midnight and resume and disposes timers', + (tester) async { + var current = DateTime(2030, 1, 1, 23, 59, 59); + final observed = []; + await tester.pumpWidget( + _app( + DeviceDayRefresh( + now: () => current, + builder: (context, date) { + observed.add(date); + return Text('${date.day}'); + }, + ), + ), + ); + current = DateTime(2030, 1, 2); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('2'), findsOneWidget); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + current = DateTime(2030, 1, 5, 12); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(find.text('5'), findsOneWidget); + final count = observed.length; + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(days: 2)); + expect(observed, hasLength(count)); + }, + ); + + testWidgets( + 'calendar follows the new today month only before manual navigation', + (tester) async { + Widget calendar(DateTime today) => _app( + MonthCalendar( + monthlySchedulesState: const MonthlySchedulesState(), + dispatchBlocEvents: false, + today: today, + ), + ); + await tester.pumpWidget(calendar(DateTime(2030, 1, 31))); + await tester.pump(); + await tester.pumpWidget(calendar(DateTime(2030, 2, 1))); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(TableCalendar)) + .focusedDay + .month, + 2, + ); + await tester.tap(find.byType(IconButton).first); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(TableCalendar)) + .focusedDay + .month, + 1, + ); + await tester.pumpWidget(calendar(DateTime(2030, 3, 1))); + await tester.pumpAndSettle(); + final value = tester.widget(find.byType(TableCalendar)); + expect(value.focusedDay.month, 1); + expect(value.currentDay?.month, 3); + await tester.pumpWidget(const SizedBox()); + }, + ); + for (final active in [false, true]) { + testWidgets( + 'read failure preserves the visible card and retry; active=$active', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final appointment = _appointment( + active + ? day.startUtc.subtract(const Duration(hours: 1)) + : now.toUtc().add(const Duration(hours: 1)), + active: active, + ); + final bloc = _ScheduleBloc( + (active + ? ScheduleState.started( + appointment, + isResumedPreparation: true, + ) + : homeReadyState(appointment)) + .copyWith( + nearestQuery: NearestQueryError( + key: homeQueryKey, + reason: NearestQueryFailureReason.storeReadFailed, + stale: homeVerifiedValue(appointment), + ), + ), + ); + await tester.pumpWidget( + _app( + BlocProvider.value( + value: bloc, + child: HomeScreenContent( + state: const MonthlySchedulesState(), + userScore: 80, + now: now, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Original appointment'), findsOneWidget); + expect( + find.text('Could not check appointments. Please try again.'), + findsOneWidget, + ); + final tile = tester.widget( + find.byKey(const Key('today_schedule_tile')), + ); + expect(tile.onTap != null, active); + await tester.tap( + find.byKey(const Key('retry-upcoming-schedule')).hitTestable(), + ); + await tester.pump(); + expect( + bloc.events.whereType(), + hasLength(1), + ); + expect(tester.takeException(), isNull); + final scroll = find.byKey(const Key('home-content-scroll')); + final calendar = find.byKey(const Key('home_month_calendar')); + final scrollable = find + .descendant(of: scroll, matching: find.byType(Scrollable)) + .first; + // The naturally sized card can put the lazy calendar beyond cache. + await tester.scrollUntilVisible(calendar, 100, scrollable: scrollable); + await tester.pumpAndSettle(); + final calendarBefore = tester.getSize(calendar); + final revealedOffset = tester + .state(scrollable) + .position + .pixels; + await tester.drag(scroll, const Offset(0, 100)); + await tester.pumpAndSettle(); + expect( + tester.state(scrollable).position.pixels, + lessThan(revealedOffset), + ); + expect(tester.getSize(calendar), calendarBefore); + await tester.drag(scroll, const Offset(0, -400)); + await tester.pumpAndSettle(); + expect( + tester.getBottomRight(calendar).dy, + lessThanOrEqualTo(568 + 0.001), + ); + expect(tester.getSize(calendar), calendarBefore); + await tester.scrollUntilVisible( + find.byKey(const Key('retry-upcoming-schedule')), + -100, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('retry-upcoming-schedule')).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + + testWidgets( + 'initial read failure offers retry without claiming there are no appointments', + (tester) async { + final bloc = _ScheduleBloc( + (const ScheduleState.initial()).copyWith( + nearestQuery: const NearestQueryError( + key: homeQueryKey, + reason: NearestQueryFailureReason.storeReadFailed, + ), + ), + ); + await tester.pumpWidget( + _app( + BlocProvider.value( + value: bloc, + child: HomeScreenContent( + state: const MonthlySchedulesState(), + userScore: 80, + now: now, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(TodaysScheduleTile), findsNothing); + await tester.tap( + find.byKey(const Key('retry-upcoming-schedule')).hitTestable(), + ); + await tester.pump(); + expect( + bloc.events.whereType(), + hasLength(1), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'actual home listener does not navigate when an existing durable run is restored', + (tester) async { + final updates = StreamController.broadcast(); + addTearDown(updates.close); + final bloc = _ScheduleBloc( + const ScheduleState.initial(), + updates: updates, + ); + final router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => Scaffold( + body: BlocProvider.value( + value: bloc, + child: HomeScreenContent( + state: const MonthlySchedulesState(), + userScore: 80, + now: now, + ), + ), + ), + ), + GoRoute( + path: '/scheduleStart', + builder: (context, state) => + const Scaffold(body: Text('Unexpected navigation')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + locale: const Locale('en'), + theme: themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + final restored = ScheduleState.started( + _appointment( + day.startUtc.subtract(const Duration(hours: 1)), + active: true, + ), + isResumedPreparation: true, + ); + bloc.state = restored; + updates.add(restored); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/home'); + expect(find.text('Unexpected navigation'), findsNothing); + expect(find.text('Preparation in progress'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/home/nearest_query_home_test.dart b/test/presentation/home/nearest_query_home_test.dart new file mode 100644 index 00000000..923772e5 --- /dev/null +++ b/test/presentation/home/nearest_query_home_test.dart @@ -0,0 +1,453 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; +import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; +import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; +import 'package:on_time_front/presentation/home/utils/home_schedule_card_selection.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; +import '../../helpers/c08_home_query_fixture.dart'; + +final _now = DateTime(2030, 1, 2, 9); + +class _Bloc extends Fake implements ScheduleBloc { + _Bloc(this.state); + @override + ScheduleState state; + final updates = StreamController.broadcast(); + final events = []; + @override + Stream get stream => updates.stream; + @override + void add(ScheduleEvent event) => events.add(event); + void update(ScheduleState value) { + state = value; + updates.add(value); + } +} + +ScheduleWithPreparationEntity _appointment({ + String id = 'next-week', + DateTime? instant, + bool active = false, +}) => ScheduleWithPreparationEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Synthetic place'), + scheduleName: id, + scheduleTime: (instant ?? _now.add(const Duration(days: 7))).toUtc(), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: active, + preparationFrozen: active, + startedAt: active ? _now.subtract(const Duration(hours: 1)).toUtc() : null, + scheduleNote: '', + preparation: const PreparationWithTimeEntity(preparationStepList: []), +); + +Future _mount( + WidgetTester tester, + _Bloc bloc, { + String locale = 'en', + double scale = 1, +}) async { + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(bloc.updates.close); + final router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute( + path: '/home', + builder: (_, _) => Scaffold( + body: HomeScreenContent( + state: const MonthlySchedulesState(), + userScore: 80, + now: _now, + ), + ), + ), + GoRoute( + path: '/scheduleStart', + builder: (_, state) => Scaffold( + body: Text('Start route:${(state.extra as Map?)?['promptVariant']}'), + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, _) => const Scaffold(body: Text('Active route')), + ), + GoRoute( + path: '/calendar', + builder: (_, _) => const Scaffold(body: Text('Calendar route')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: MaterialApp.router( + routerConfig: router, + theme: themeData, + locale: Locale(locale), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return router; +} + +Future _tap(WidgetTester tester, Key key) async { + final finder = find.byKey(key); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + expect(finder.hitTestable(), findsOneWidget); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + final fonts = FontLoader('Pretendard'); + for (final weight in ['Regular', 'Medium', 'SemiBold', 'Bold']) { + fonts.addFont(rootBundle.load('assets/fonts/Pretendard-$weight.ttf')); + } + await fonts.load(); + }); + testWidgets( + 'fresh next-week appointment has one Next card, fixed resolution and explicit start route', + (tester) async { + final schedule = _appointment(); + final verified = homeVerifiedValue(schedule); + // A UI result is independent of the compatibility ScheduleStatus field. + final bloc = _Bloc( + const ScheduleState.initial().copyWith( + nearestQuery: NearestQueryReady(value: verified), + ), + ); + final router = await _mount(tester, bloc); + expect(find.text('Next appointment'), findsOneWidget); + expect(find.text("Today's Appointments"), findsNothing); + expect(find.byType(TodaysScheduleTile), findsOneWidget); + expect( + tester + .widget(find.byType(ScheduleZonedTime)) + .resolution, + same(verified.resolution), + ); + await _tap(tester, const Key('today_schedule_tile')); + expect(router.routeInformationProvider.value.uri.path, '/scheduleStart'); + expect(find.text('Start route:earlyStart'), findsOneWidget); + expect(bloc.events.whereType(), isEmpty); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'fresh today appointment has only Today, not a duplicate Next card', + (tester) async { + final bloc = _Bloc( + homeReadyState( + _appointment(instant: _now.add(const Duration(hours: 2))), + ), + ); + await _mount(tester, bloc); + expect(find.text("Today's Appointments"), findsOneWidget); + expect(find.text('Next appointment'), findsNothing); + expect(find.byType(TodaysScheduleTile), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }, + ); + + final stale = homeVerifiedValue(_appointment()); + final incomplete = { + 'loading': NearestQueryLoading( + key: homeQueryKey, + progress: homeQueryProgress, + stale: stale, + ), + 'error': NearestQueryError( + key: homeQueryKey, + reason: NearestQueryFailureReason.preparationMissing, + stale: stale, + ), + 'interrupted': NearestQueryLimited( + key: homeQueryKey, + reason: NearestQueryLimitReason.interrupted, + progress: homeQueryProgress, + canContinue: true, + canRetry: false, + stale: stale, + ), + }; + for (final entry in incomplete.entries) { + testWidgets( + '${entry.key} keeps previous details read-only and sends the displayed query key', + (tester) async { + final bloc = _Bloc( + const ScheduleState.initial().copyWith(nearestQuery: entry.value), + ); + final router = await _mount(tester, bloc, scale: 2.5); + expect(find.text('Previously checked appointment'), findsOneWidget); + expect(find.text('Next appointment'), findsNothing); + final tile = tester.widget( + find.byType(TodaysScheduleTile), + ); + expect(tile.schedule?.id, 'next-week'); + expect(tile.onTap, isNull); + final action = switch (entry.key) { + 'loading' => const Key('cancel-nearest-query'), + 'error' => const Key('retry-upcoming-schedule'), + _ => const Key('continue-nearest-query'), + }; + await _tap(tester, action); + final event = bloc.events.single; + final sentKey = switch (event) { + ScheduleNearestQueryCancelRequested() => event.queryKey, + ScheduleNearestQueryRetryRequested() => event.queryKey, + ScheduleNearestQueryContinueRequested() => event.queryKey, + _ => null, + }; + expect(sentKey, homeQueryKey); + expect(router.routeInformationProvider.value.uri.path, '/home'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + + testWidgets( + 'only a complete Empty result claims there are no upcoming appointments', + (tester) async { + final bloc = _Bloc(const ScheduleState.initial()); + await _mount(tester, bloc); + for (final query in [ + const NearestQueryIdle(), + const NearestQueryLoading( + key: homeQueryKey, + progress: homeQueryProgress, + ), + const NearestQueryError( + key: homeQueryKey, + reason: NearestQueryFailureReason.storeReadFailed, + ), + const NearestQueryLimited( + key: homeQueryKey, + reason: NearestQueryLimitReason.searchBudgetExhausted, + progress: homeQueryProgress, + canContinue: false, + canRetry: false, + ), + ]) { + bloc.update( + const ScheduleState.initial().copyWith(nearestQuery: query), + ); + await tester.pumpAndSettle(); + expect(find.byType(TodaysScheduleTile), findsNothing); + expect(find.text('No upcoming appointments.'), findsNothing); + expect(find.text('No appointments today'), findsNothing); + } + bloc.update(homeEmptyState()); + await tester.pumpAndSettle(); + expect(find.text('No upcoming appointments.'), findsOneWidget); + expect( + tester + .widget(find.byType(TodaysScheduleTile)) + .onTap, + isNull, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'terminal budget limit exposes Calendar but no retry or continue loophole at large Korean text', + (tester) async { + final bloc = _Bloc( + const ScheduleState.initial().copyWith( + nearestQuery: const NearestQueryLimited( + key: homeQueryKey, + reason: NearestQueryLimitReason.searchBudgetExhausted, + progress: homeQueryProgress, + canContinue: false, + canRetry: false, + ), + ), + ); + final router = await _mount(tester, bloc, locale: 'ko', scale: 2.5); + expect(find.byKey(const Key('retry-upcoming-schedule')), findsNothing); + expect(find.byKey(const Key('continue-nearest-query')), findsNothing); + await _tap(tester, const Key('nearest-query-calendar')); + expect(router.routeInformationProvider.value.uri.path, '/calendar'); + expect(bloc.events, isEmpty); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + test( + 'durable active and explicit notification owners survive every query state', + () { + final active = _appointment( + id: 'active', + instant: _now.subtract(const Duration(days: 1)), + active: true, + ); + final prompt = _appointment(id: 'prompt'); + for (final query in [ + const NearestQueryIdle(), + ...incomplete.values, + NearestQueryReady(value: stale), + NearestQueryEmpty(authority: homeQueryAuthority()), + ]) { + final activeState = ScheduleState.started( + active, + isResumedPreparation: true, + ).copyWith(nearestQuery: query); + expect( + HomeScheduleCardSelection.at(activeState, _now).schedule?.id, + 'active', + ); + expect( + HomeScheduleCardSelection.at(activeState, _now).isActive, + isTrue, + ); + final promptState = ScheduleState.upcoming( + prompt, + notificationPromptOwner: Object(), + ).copyWith(nearestQuery: query); + final selection = HomeScheduleCardSelection.at(promptState, _now); + expect(selection.schedule?.id, 'prompt'); + expect(selection.kind, HomeScheduleCardKind.prompt); + } + expect( + HomeScheduleCardSelection.at( + ScheduleState.started(prompt), + _now, + ).schedule, + isNull, + ); + }, + ); + + testWidgets( + 'active preparation stays actionable while query cancellation only targets the query', + (tester) async { + final active = _appointment( + id: 'active', + instant: _now.subtract(const Duration(days: 1)), + active: true, + ); + final bloc = _Bloc( + ScheduleState.started( + active, + isResumedPreparation: true, + ).copyWith(nearestQuery: incomplete['loading']), + ); + final router = await _mount(tester, bloc); + expect(find.text('Preparation in progress'), findsOneWidget); + expect( + find.textContaining('These are previously checked details'), + findsNothing, + ); + expect( + tester + .widget(find.byType(TodaysScheduleTile)) + .schedule + ?.id, + 'active', + ); + await _tap(tester, const Key('cancel-nearest-query')); + expect(bloc.events.single, isA()); + await _tap(tester, const Key('today_schedule_tile')); + expect(router.routeInformationProvider.value.uri.path, '/alarmScreen'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'a callback retained from an older query sends its old key, never the replacement key', + (tester) async { + final bloc = _Bloc( + const ScheduleState.initial().copyWith( + nearestQuery: incomplete['error'], + ), + ); + await _mount(tester, bloc); + final oldAction = tester + .widget(find.byKey(const Key('retry-upcoming-schedule'))) + .onPressed!; + const replacement = NearestQueryKey(generation: 8, epoch: 3, revision: 6); + bloc.update( + const ScheduleState.initial().copyWith( + nearestQuery: const NearestQueryError( + key: replacement, + reason: NearestQueryFailureReason.storeReadFailed, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(TodaysScheduleTile), findsNothing); + oldAction(); + expect( + (bloc.events.single as ScheduleNearestQueryRetryRequested).queryKey, + homeQueryKey, + ); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'a resolved next appointment still exposes other time issues for calendar review', + (tester) async { + final bloc = _Bloc( + homeReadyState(_appointment()).copyWith( + nearestQuery: NearestQueryReady( + value: stale, + issues: const [ + NearestQueryIssue( + reason: NearestQueryIssueReason.unknownTimeZone, + scheduleId: 'needs-review', + ), + ], + ), + ), + ); + final router = await _mount(tester, bloc); + expect(find.text('Next appointment'), findsOneWidget); + expect( + find.textContaining('Some appointment times need review'), + findsOneWidget, + ); + await _tap(tester, const Key('nearest-query-calendar')); + expect(router.routeInformationProvider.value.uri.path, '/calendar'); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/home/screens/home_screen_tmp_test.dart b/test/presentation/home/screens/home_screen_tmp_test.dart index a8f27b1d..a2a0a111 100644 --- a/test/presentation/home/screens/home_screen_tmp_test.dart +++ b/test/presentation/home/screens/home_screen_tmp_test.dart @@ -1,3 +1,4 @@ +import '../../../helpers/c08_home_query_fixture.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -29,6 +30,9 @@ import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; import 'package:on_time_front/presentation/shared/components/arc_indicator.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; +// Explicit local observation and absolute commitments keep 'today' deterministic. +final _homeNow = DateTime(2030, 1, 2, 9); + class StubAuthBloc extends Mock implements AuthBloc { StubAuthBloc(this._state); @@ -147,6 +151,7 @@ void main() { width: size.width, height: size.height, child: HomeScreenContent( + now: _homeNow, state: const MonthlySchedulesState( status: MonthlySchedulesStatus.success, ), @@ -170,8 +175,8 @@ void main() { buildSubject( size: const Size(430, 932), scheduleState: hasSchedule - ? ScheduleState.upcoming(_shortSchedule()) - : const ScheduleState.notExists(), + ? homeReadyState(_shortSchedule()) + : homeEmptyState(), ), ); await tester.pump(); @@ -203,7 +208,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final scheduleBloc = StreamingScheduleBloc( - ScheduleState.upcoming(_shortSchedule()), + homeReadyState(_shortSchedule()), ); addTearDown(scheduleBloc.close); await tester.pumpWidget( @@ -214,7 +219,7 @@ void main() { ); await tester.pump(); final router = GoRouter.of(tester.element(find.byType(HomeScreenContent))); - final now = DateTime.now(); + final now = _homeNow; final lastDay = DateTime(now.year, now.month + 1, 0); final day = find .descendant( @@ -264,7 +269,7 @@ void main() { buildSubject( size: const Size(360, 640), textScale: 1.3, - scheduleState: const ScheduleState.notExists(), + scheduleState: homeEmptyState(), ), ); await tester.pump(); @@ -317,7 +322,7 @@ void main() { ), ), ); - final scheduleBloc = StubScheduleBloc(const ScheduleState.notExists()); + final scheduleBloc = StubScheduleBloc(homeEmptyState()); await tester.pumpWidget( MultiBlocProvider( @@ -335,6 +340,7 @@ void main() { ); await tester.pumpAndSettle(); + // This mounts the actual DeviceDayRefresh wrapper, which owns its clock. final today = DateTime.now(); expect(loadUseCase.calls, hasLength(1)); expect( @@ -356,7 +362,7 @@ void main() { buildSubject( size: const Size(360, 640), textScale: 1.3, - scheduleState: ScheduleState.upcoming(_scheduleWithLongName()), + scheduleState: homeReadyState(_scheduleWithLongName()), ), ); await tester.pump(); @@ -378,10 +384,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - buildSubject( - size: const Size(390, 844), - scheduleState: const ScheduleState.notExists(), - ), + buildSubject(size: const Size(390, 844), scheduleState: homeEmptyState()), ); await tester.pump(); @@ -412,7 +415,7 @@ void main() { buildSubject( size: const Size(390, 844), padding: const EdgeInsets.only(top: 59), - scheduleState: const ScheduleState.notExists(), + scheduleState: homeEmptyState(), ), ); await tester.pump(); @@ -495,7 +498,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final scheduleBloc = StreamingScheduleBloc(const ScheduleState.notExists()); + final scheduleBloc = StreamingScheduleBloc(homeEmptyState()); addTearDown(scheduleBloc.close); await tester.pumpWidget( @@ -521,7 +524,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final scheduleBloc = StreamingScheduleBloc( - ScheduleState.upcoming(_shortSchedule()), + homeReadyState(_shortSchedule()), ); addTearDown(scheduleBloc.close); @@ -539,14 +542,18 @@ void main() { expect(find.text('Schedule Start:earlyStart'), findsOneWidget); }); - testWidgets('today ongoing tile opens active alarm route', (tester) async { + testWidgets('explicitly owned ongoing preparation opens its alarm route', ( + tester, + ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(360, 640); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final scheduleBloc = StreamingScheduleBloc( - ScheduleState.ongoing(_shortSchedule()), + ScheduleState.ongoing( + _shortSchedule(), + ).copyWith(hasNotificationPreparationOwner: true), ); addTearDown(scheduleBloc.close); @@ -610,6 +617,8 @@ class _StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { } class _StubDeleteScheduleUseCase implements DeleteScheduleUseCase { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @override Future call(ScheduleEntity schedule) async {} } @@ -664,6 +673,7 @@ Widget _buildRoutedSubject({ width: size.width, height: size.height, child: HomeScreenContent( + now: _homeNow, state: const MonthlySchedulesState( status: MonthlySchedulesStatus.success, ), @@ -737,7 +747,9 @@ ScheduleWithPreparationEntity _scheduleWithLongName() { place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Very long appointment name that should never force the home screen to scroll', - scheduleTime: DateTime.now().add(const Duration(hours: 3)), + scheduleTime: _homeNow.toUtc().add(const Duration(hours: 3)), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 20), isChanged: false, isStarted: false, @@ -761,7 +773,9 @@ ScheduleWithPreparationEntity _shortSchedule() { id: 'schedule-short', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Standup', - scheduleTime: DateTime.now().add(const Duration(hours: 3)), + scheduleTime: _homeNow.toUtc().add(const Duration(hours: 3)), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 20), isChanged: false, isStarted: false, diff --git a/test/presentation/my_page/detailed_notification_settings_cubit_test.dart b/test/presentation/my_page/detailed_notification_settings_cubit_test.dart new file mode 100644 index 00000000..bb63b237 --- /dev/null +++ b/test/presentation/my_page/detailed_notification_settings_cubit_test.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/presentation/my_page/cubit/detailed_notification_settings_cubit.dart'; + +void main() { + late LocalDataOperationGate gate; + late AlarmOperationCoordinator owner; + late _Preferences preferences; + late _Reconcile reconcile; + late DetailedNotificationSettingsCubit cubit; + setUp(() { + gate = LocalDataOperationGate(); + owner = AlarmOperationCoordinator(gate); + preferences = _Preferences(gate); + reconcile = _Reconcile(); + cubit = DetailedNotificationSettingsCubit.test( + preferences, + reconcile, + operations: owner, + delayNotice: const Duration(milliseconds: 20), + ); + }); + tearDown(() async { + await cubit.close(); + owner.dispose(); + gate.dispose(); + }); + + test('unknown loading and read failure never become a saved false', () async { + preferences.readBarrier = Completer(); + final refresh = cubit.refresh(); + expect(cubit.state.displayedEnabled, isNull); + expect(cubit.state.canRequest, isFalse); + cubit.request(true); + expect(preferences.writes, isEmpty); + preferences.failRead = true; + preferences.readBarrier!.complete(); + await refresh; + expect(cubit.state.load, DetailedPreferenceLoad.failed); + expect(cubit.state.confirmedEnabled, isNull); + preferences.failRead = false; + preferences.readBarrier = null; + preferences.value = true; + await cubit.retry(); + expect(cubit.state.confirmedEnabled, isTrue); + }); + + test('late resume read cannot overwrite a newer requested OFF', () async { + preferences.value = true; + await cubit.refresh(); + preferences.readBarrier = Completer(); + final refresh = cubit.refresh(); + cubit.request(false); + preferences.readBarrier!.complete(); + await refresh; + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isFalse); + expect(preferences.value, isFalse); + }); + + test( + 'latest intent drains writes serially and skips obsolete ON delivery', + () async { + await cubit.refresh(); + final baseline = reconcile.calls; + preferences.writeBarrier = Completer(); + cubit.request(true); + cubit.request(false); + expect(cubit.state.requestedEnabled, isFalse); + expect(owner.allowsDetailed(owner.captureContentPermit()), isFalse); + preferences.writeBarrier!.complete(); + await pumpEventQueue(); + expect(preferences.writes, [true, false]); + expect(preferences.maxConcurrentWrites, 1); + expect(reconcile.calls, baseline + 1); + expect(cubit.state.confirmedEnabled, isFalse); + expect(cubit.state.requestedEnabled, isNull); + }, + ); + + test( + 'burst keeps only last waiting value and duplicate choice has no extra write', + () async { + await cubit.refresh(); + preferences.writeBarrier = Completer(); + cubit.request(true); + cubit.request(false); + cubit.request(true); + cubit.request(true); + preferences.writeBarrier!.complete(); + await pumpEventQueue(); + expect(preferences.writes, [true, true]); + expect(cubit.state.confirmedEnabled, isTrue); + final before = preferences.writes.length; + cubit.request(true); + await pumpEventQueue(); + expect(preferences.writes.length, before); + }, + ); + + test( + 'failed OFF keeps confirmed ON and its hold until explicit ON', + () async { + preferences.value = true; + await cubit.refresh(); + preferences.failWrite = true; + cubit.request(false); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isTrue); + expect(cubit.state.requestedEnabled, isFalse); + expect(cubit.state.save, DetailedPreferenceSave.failed); + expect(owner.allowsDetailed(owner.captureContentPermit()), isFalse); + final writes = preferences.writes.length; + await pumpEventQueue(); + expect(preferences.writes.length, writes); + preferences.failWrite = false; + cubit.request(true); + await pumpEventQueue(); + expect(owner.allowsDetailed(owner.captureContentPermit()), isTrue); + }, + ); + + test( + 'committed write response failure uses readback and does not duplicate write', + () async { + await cubit.refresh(); + preferences.failAfterCommit = true; + cubit.request(true); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isTrue); + expect(cubit.state.save, DetailedPreferenceSave.idle); + expect(preferences.writes, [true]); + }, + ); + + test( + 'write and readback failure remain unknown, explicit retry recovers', + () async { + preferences.value = true; + await cubit.refresh(); + preferences.failWrite = true; + preferences.failRead = true; + cubit.request(false); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isNull); + expect(cubit.state.save, DetailedPreferenceSave.failed); + expect(cubit.state.canRequest, isFalse); + preferences.failWrite = false; + preferences.failRead = false; + await cubit.retry(); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isFalse); + }, + ); + + test( + 'an unreturned platform Future does not block the next OFF DB commit', + () async { + await cubit.refresh(); + reconcile.block = Completer(); + cubit.request(true); + await pumpEventQueue(); + cubit.request(false); + await pumpEventQueue(); + expect(preferences.value, isFalse); + expect(cubit.state.confirmedEnabled, isFalse); + final calls = reconcile.calls; + await cubit.retry(); + expect(reconcile.calls, calls); + reconcile.block!.complete(_result()); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isFalse); + }, + ); + + test( + 'failed OFF persistence retries while an older OS Future is unreturned', + () async { + await cubit.refresh(); + reconcile.block = Completer(); + cubit.request(true); + await pumpEventQueue(); + preferences.failWrite = true; + cubit.request(false); + await pumpEventQueue(); + expect(cubit.state.save, DetailedPreferenceSave.failed); + preferences.failWrite = false; + await cubit.retry(); + expect(preferences.value, isFalse); + expect(cubit.state.confirmedEnabled, isFalse); + reconcile.block!.complete(_result()); + await pumpEventQueue(); + }, + ); + + test('failed ON does not claim an unresolved OFF hold', () async { + await cubit.refresh(); + preferences.failWrite = true; + cubit.request(true); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isFalse); + expect(cubit.state.requestedEnabled, isTrue); + expect(cubit.state.delivery, DetailedPreferenceDelivery.needsCheck); + expect(cubit.state.save, DetailedPreferenceSave.failed); + preferences.failWrite = false; + await cubit.retry(); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isTrue); + }); + + test( + 'route unsubscribe does not discard pending OFF; new subscriber sees it', + () async { + preferences.value = true; + await cubit.refresh(); + final subscription = cubit.stream.listen((_) {}); + preferences.writeBarrier = Completer(); + cubit.request(false); + await subscription.cancel(); + expect(cubit.state.requestedEnabled, isFalse); + preferences.writeBarrier!.complete(); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isFalse); + expect(preferences.value, isFalse); + }, + ); + + test( + 'generation change drops waiting intent and never writes the new store', + () async { + await cubit.refresh(); + preferences.writeBarrier = Completer(); + cubit.request(true); + cubit.request(false); + await gate.run(() async {}, replacesData: true); + preferences.writeBarrier!.complete(); + await pumpEventQueue(); + expect(preferences.value, isFalse); + expect(preferences.writes, [true]); + expect(cubit.state.requestedEnabled, isNull); + expect(cubit.state.load, DetailedPreferenceLoad.unavailable); + await cubit.refresh(); + expect(cubit.state.load, DetailedPreferenceLoad.ready); + }, + ); + + for (final entry in { + AlarmFailureReason.cancellationFailed: + DetailedPreferenceDelivery.cancellationUnconfirmed, + AlarmFailureReason.observationFailed: DetailedPreferenceDelivery.needsCheck, + AlarmFailureReason.platformError: + DetailedPreferenceDelivery.schedulingFailed, + AlarmFailureReason.contentDeferred: + DetailedPreferenceDelivery.contentDeferred, + }.entries) { + test('delivery ${entry.key} preserves the saved preference', () async { + preferences.value = true; + await cubit.refresh(); + reconcile.result = _result(reason: entry.key); + cubit.request(false); + await pumpEventQueue(); + expect(cubit.state.confirmedEnabled, isFalse); + expect(cubit.state.delivery, entry.value); + final writes = preferences.writes.length; + reconcile.result = _result(); + await cubit.retry(); + expect(preferences.writes.length, writes); + expect(cubit.state.delivery, DetailedPreferenceDelivery.noUpcoming); + }); + } +} + +AlarmReconciliationResult _result({AlarmFailureReason? reason}) { + final now = DateTime.utc(2030); + return AlarmReconciliationResult( + status: reason == null + ? AlarmReconciliationStatus.armed + : AlarmReconciliationStatus.partial, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: [if (reason != null) AlarmFailure(reason: reason)], + scheduleWindowStart: now, + scheduleWindowEnd: now, + alarmCoverageStart: now, + alarmCoverageEnd: now, + ); +} + +class _Reconcile implements ReconcileAlarmsUseCase { + int calls = 0; + AlarmReconciliationResult result = _result(); + Completer? block; + @override + Future call() async { + calls++; + return block == null ? result : await block!.future; + } +} + +class _Preferences implements DetailedNotificationPreferencePort { + _Preferences(this.gate); + final LocalDataOperationGate gate; + bool value = false; + bool failRead = false, failWrite = false, failAfterCommit = false; + Completer? readBarrier, writeBarrier; + final writes = []; + int concurrentWrites = 0, maxConcurrentWrites = 0; + DetailedNotificationPreferenceSnapshot snapshot( + int generation, + bool enabled, + ) => DetailedNotificationPreferenceSnapshot( + generation: generation, + detailedEnabled: enabled, + scheduleNotificationsEnabled: true, + ); + @override + Future read({ + required int expectedGeneration, + }) async { + final old = value; + await readBarrier?.future; + gate.checkWrite(expectedGeneration); + if (failRead) throw StateError('synthetic read failure'); + return snapshot(expectedGeneration, old); + } + + @override + Future write( + bool enabled, { + required int expectedGeneration, + }) async { + writes.add(enabled); + concurrentWrites++; + if (concurrentWrites > maxConcurrentWrites) { + maxConcurrentWrites = concurrentWrites; + } + try { + await writeBarrier?.future; + gate.checkWrite(expectedGeneration); + if (failWrite) throw StateError('synthetic write failure'); + value = enabled; + if (failAfterCommit) throw StateError('synthetic response failure'); + return snapshot(expectedGeneration, value); + } finally { + concurrentWrites--; + } + } +} diff --git a/test/presentation/my_page/my_data_recurring_time_review_test.dart b/test/presentation/my_page/my_data_recurring_time_review_test.dart new file mode 100644 index 00000000..b6ae7281 --- /dev/null +++ b/test/presentation/my_page/my_data_recurring_time_review_test.dart @@ -0,0 +1,130 @@ +// Actual MyData -> time review -> recurrence -> full validation -> final confirm. +// Typed fake ports isolate route ownership; ciphertext/SQLite are tested apart. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/ports/backup_time_review_port.dart'; +import 'package:on_time_front/domain/ports/local_data_ports.dart'; +import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; +import 'package:on_time_front/presentation/backup/backup_recurring_time_review_screen.dart'; +import 'package:on_time_front/presentation/backup/backup_time_review_screen.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import '../backup/backup_recurring_time_review_screen_test.dart' as recurring; +import '../backup/backup_time_review_screen_test.dart' show ReadyInput; + +class _Candidate extends recurring.Candidate { + _Candidate() : super('recurring-current-owner'); + final ready = ReadyInput(); + @override + Future revalidate() async { + validations++; + return choices.isEmpty ? this : ready; + } +} + +class _Port extends Fake implements BackupOperationsPort, BackupTimeReviewPort { + final review = _Candidate(); + int applied = 0; + @override + int generation = 0; + @override + Future freshness() async => + const BackupFreshnessStatus(freshness: BackupFreshness.neverExported); + @override + Future selectForRestore(String password) async => + review; + @override + Future apply(BackupRestoreInput input) async { + expect(input, same(review.ready)); + applied++; + return ++generation; + } +} + +class _Delivery extends Fake implements RestoreDeliveryPort { + int calls = 0; + @override + Future reconcile() async { + calls++; + return true; + } +} + +void main() { + Future tap(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + final scrollable = find.byType(Scrollable).last; + final state = tester.state(scrollable); + state.position.jumpTo(0); + await tester.pump(); + await tester.scrollUntilVisible(finder, 180, scrollable: scrollable); + await Scrollable.ensureVisible(tester.element(finder), alignment: .5); + await tester.pump(); + await tester.tap(finder); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + Future open(WidgetTester tester, _Port port, _Delivery delivery) async { + await tester.pumpWidget( + MaterialApp(home: MyDataScreen(workflow: BackupWorkflow(port, delivery))), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('백업에서 복원')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'recurring route password'); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + await tap(tester, 'backup-time-issue-rule-1'); + await tap(tester, 'backup-time-recurrence'); + expect(find.byType(BackupRecurringTimeReviewScreen), findsOneWidget); + expect(port.review.disposals, 0); + expect(port.applied, 0); + } + + for (final apply in [false, true]) { + testWidgets( + 'recurring choice needs full revalidation and separate final ${apply ? 'apply' : 'cancel'}', + (tester) async { + final port = _Port(), delivery = _Delivery(); + await open(tester, port, delivery); + await tap(tester, 'review-backup-recurrence'); + await tap(tester, 'choose-backup-recurrence'); + await tester.tap( + find.byKey(const Key('confirm-backup-recurrence-choice')), + ); + await tester.pumpAndSettle(); + expect(port.review.choices, hasLength(1)); + expect(port.review.validations, 1); + expect(find.text('복원 내용 확인'), findsOneWidget); + expect(port.applied, 0); + expect(delivery.calls, 0); + await tester.tap(find.text(apply ? '복원' : '취소')); + await tester.pumpAndSettle(); + expect(port.applied, apply ? 1 : 0); + expect(delivery.calls, apply ? 1 : 0); + expect(port.review.ready.disposed, isTrue); + expect(port.review.disposals, greaterThan(0)); + }, + ); + } + testWidgets( + 'cancel recurring subreview retains its parent lease and cannot apply', + (tester) async { + final port = _Port(), delivery = _Delivery(); + await open(tester, port, delivery); + await tap(tester, 'cancel-backup-recurrence'); + await tester.pumpAndSettle(); + expect(find.byType(BackupTimeReviewScreen), findsOneWidget); + expect(port.review.choices, isEmpty); + expect(port.review.validations, 1); + expect(port.review.disposals, 0); + expect(port.applied, 0); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + expect(port.review.disposals, greaterThan(0)); + expect(delivery.calls, 0); + }, + ); +} diff --git a/test/presentation/my_page/my_data_restore_test.dart b/test/presentation/my_page/my_data_restore_test.dart index 9c2c1529..c3d6c0f5 100644 --- a/test/presentation/my_page/my_data_restore_test.dart +++ b/test/presentation/my_page/my_data_restore_test.dart @@ -1,3 +1,6 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/restore_staging_fixture.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; import 'dart:async'; import 'package:on_time_front/domain/ports/local_data_ports.dart'; import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; @@ -54,6 +57,8 @@ class _RestoreService extends Fake implements BackupOperationsPort { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); late AppDatabase database; late _RestoreService service; late _Delivery delivery; @@ -72,6 +77,11 @@ void main() { database, _Metadata(), NoopAlarmCleanup(), + ingestionFactory: memoryBackupIngestion, + processingOwner: testBackupProcessingOwner(), + stagingFactory: memoryRestoreStaging, + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: noPlatformRestoreCleanup, crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), ); final candidate = await delegate.previewEncryptedBackup( @@ -175,7 +185,7 @@ void main() { await tester.runAsync(() => service.appliedDone.future); await tester.pumpAndSettle(); expect( - find.textContaining('데이터는 복원됐지만 알림 처리가 완료되지 않았습니다.'), + find.textContaining('데이터는 복원됐지만 남은 정리와 알림 처리가 완료되지 않았습니다.'), findsWidgets, ); expect(service.applied, 1); @@ -184,7 +194,7 @@ void main() { ); expect(before!.note, 'synthetic backup'); delivery.succeeds = true; - await tester.tap(find.text('알림 처리만 다시 시도')); + await tester.tap(find.text('남은 처리 다시 시도')); await tester.pumpAndSettle(); expect(find.text('현재 데이터의 알림 처리를 완료했습니다.'), findsOneWidget); expect(service.applied, 1); diff --git a/test/presentation/my_page/my_data_time_review_test.dart b/test/presentation/my_page/my_data_time_review_test.dart new file mode 100644 index 00000000..f2be70a6 --- /dev/null +++ b/test/presentation/my_page/my_data_time_review_test.dart @@ -0,0 +1,155 @@ +// Actual MyData route + workflow selection/ready ownership, with typed port +// fixtures. Authenticated crypto/SQLite are covered by service tests separately. +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/ports/backup_time_review_port.dart'; +import 'package:on_time_front/domain/ports/local_data_ports.dart'; +import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import 'package:on_time_front/presentation/backup/backup_time_review_screen.dart'; +import '../backup/backup_time_review_screen_test.dart' + show ReviewInput, ReadyInput; + +class _Port extends Fake implements BackupOperationsPort, BackupTimeReviewPort { + final review = ReviewInput(); + final ready = ReadyInput(); + Completer? pending; + int applied = 0, selected = 0; + @override + int generation = 0; + @override + Future freshness() async => + const BackupFreshnessStatus(freshness: BackupFreshness.neverExported); + @override + Future selectForRestore(String password) async { + selected++; + return pending?.future ?? review; + } + + @override + Future preview(String password) => throw StateError( + 'ready-only legacy API must not receive unresolved input', + ); + @override + Future apply(BackupRestoreInput input) async { + expect(input, same(ready)); + applied++; + return ++generation; + } +} + +class _Delivery extends Fake implements RestoreDeliveryPort { + int calls = 0; + @override + Future reconcile() async { + calls++; + return true; + } +} + +void main() { + Future select( + WidgetTester tester, + _Port port, + _Delivery delivery, + ) async { + await tester.pumpWidget( + MaterialApp(home: MyDataScreen(workflow: BackupWorkflow(port, delivery))), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('백업에서 복원')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField), + 'review integration password', + ); + await tester.tap(find.text('계속')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + Future makeReady(WidgetTester tester, _Port port) async { + port.review.ready = port.ready; + final button = find.byKey(const Key('revalidate-backup-time')); + await tester.scrollUntilVisible( + button, + 200, + scrollable: find.byType(Scrollable).last, + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.text('복원 내용 확인'), findsOneWidget); + expect(port.applied, 0); + } + + testWidgets('review cancel cannot apply and disposes provisional owner', ( + tester, + ) async { + final port = _Port(); + final delivery = _Delivery(); + await select(tester, port, delivery); + expect(find.byType(BackupTimeReviewScreen), findsOneWidget); + expect(port.applied, 0); + final button = find.byKey(const Key('cancel-backup-time')); + await tester.scrollUntilVisible( + button, + 200, + scrollable: find.byType(Scrollable).last, + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(port.review.disposed, isTrue); + expect(port.applied, 0); + expect(delivery.calls, 0); + expect(find.byType(BackupTimeReviewScreen), findsNothing); + }); + testWidgets('fresh ready still requires independent final confirmation', ( + tester, + ) async { + final port = _Port(); + final delivery = _Delivery(); + await select(tester, port, delivery); + await makeReady(tester, port); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(port.applied, 0); + expect(port.ready.disposed, isTrue); + expect(port.review.disposeCalls, greaterThan(0)); + expect(delivery.calls, 0); + }); + testWidgets( + 'only fresh ready applies once and cleanup releases both owners', + (tester) async { + final port = _Port(); + final delivery = _Delivery(); + await select(tester, port, delivery); + await makeReady(tester, port); + await tester.tap(find.text('복원')); + await tester.pumpAndSettle(); + expect(port.applied, 1); + expect(delivery.calls, 1); + expect(port.ready.disposed, isTrue); + expect(port.review.disposeCalls, greaterThan(0)); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('late picker result after route disposal cannot open review', ( + tester, + ) async { + final port = _Port()..pending = Completer(); + final delivery = _Delivery(); + await select(tester, port, delivery); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + port.pending!.complete(port.review); + await tester.pumpAndSettle(); + expect(port.review.disposed, isTrue); + expect(port.applied, 0); + expect(delivery.calls, 0); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/presentation/my_page/my_data_unknown_restore_test.dart b/test/presentation/my_page/my_data_unknown_restore_test.dart new file mode 100644 index 00000000..2b5a0d05 --- /dev/null +++ b/test/presentation/my_page/my_data_unknown_restore_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; + +class _Candidate extends BackupRestoreInput { + int releases = 0; + @override + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: DateTime.utc(2030), + sourceAppVersion: 'test', + sourcePlatform: 'android', + scheduleCount: 2, + templateCount: 0, + defaultPreparationStepCount: 1, + ); + @override + Future dispose() async { + releases++; + } +} + +class _UnknownWorkflow extends Fake implements BackupWorkflow { + final candidate = _Candidate(); + int restored = 0; + @override + int get generation => 1; + @override + Future freshness() async => + const BackupFreshnessStatus(freshness: BackupFreshness.neverExported); + @override + Future preview(String password) async => candidate; + @override + Future selectForRestore(String password) async => + candidate; + @override + Future restore(BackupRestoreInput input) async { + restored++; + return const BackupRestoreReceipt.uncertain(generation: 1); + } +} + +void main() { + setUp(() => getIt.reset()); + tearDown(() => getIt.reset()); + testWidgets( + 'unknown commit cannot turn into failure, retry delivery, export, restore, or reset', + (tester) async { + final workflow = _UnknownWorkflow(); + getIt.registerSingleton(workflow); + await tester.pumpWidget(const MaterialApp(home: MyDataScreen())); + await tester.pumpAndSettle(); + await tester.tap(find.text('백업에서 복원')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField), + 'portable backup password', + ); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + await tester.tap(find.text('복원')); + await tester.pumpAndSettle(); + expect(workflow.restored, 1); + expect(workflow.candidate.releases, 0); + for (final tile in tester.widgetList(find.byType(ListTile))) { + expect(tile.enabled, false); + } + expect(find.textContaining('not yet confirmed'), findsWidgets); + final retry = tester.widgetList(find.byType(TextButton)); + expect(retry.every((button) => button.onPressed == null), true); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index cec4855a..2757a253 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/presentation/my_page/cubit/detailed_notification_settings_cubit.dart'; import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; import 'package:on_time_front/domain/entities/delivery_observation.dart'; import 'package:on_time_front/core/database/local_data_operation_gate.dart'; @@ -34,6 +35,9 @@ void main() { setUpAll(loadRefreshFonts); late AppDatabase database; + late LocalDataOperationGate detailGate; + late AlarmOperationCoordinator detailOwner; + late _ControlledPreferenceService detailPreferences; late _FakeAlarmRepository alarmRepository; late _FakeAlarmRegistry alarmRegistry; late _FakeAlarmSchedulerService scheduler; @@ -45,6 +49,9 @@ void main() { SharedPreferences.setMockInitialValues({}); await getIt.reset(); database = AppDatabase.forTesting(NativeDatabase.memory()); + detailGate = LocalDataOperationGate(); + detailOwner = AlarmOperationCoordinator(detailGate); + detailPreferences = _ControlledPreferenceService(database, detailGate); await database.userDao.putUser( const UserEntity( id: localProfileId, @@ -52,7 +59,7 @@ void main() { note: '', ), ); - alarmRepository = _FakeAlarmRepository(); + alarmRepository = _FakeAlarmRepository(database); alarmRegistry = _FakeAlarmRegistry(); scheduler = _FakeAlarmSchedulerService(); fallback = _FakeFallbackAlarmNotificationService(); @@ -65,21 +72,414 @@ void main() { cancelAll = _FakeCancelAllAlarmsUseCase(alarmRegistry, scheduler, fallback); getIt ..registerSingleton( - DetailedNotificationPreferenceService(database), + detailPreferences, ) ..registerSingleton(alarmRepository) ..registerSingleton(alarmRegistry) ..registerSingleton(scheduler) ..registerSingleton(fallback) ..registerSingleton(reconcile) - ..registerSingleton(cancelAll); + ..registerSingleton(cancelAll) + ..registerSingleton( + DetailedNotificationSettingsCubit( + getIt(), + reconcile, + operations: detailOwner, + ), + dispose: (controller) => controller.close(), + ); }); tearDown(() async { - await database.close(); await getIt.reset(); + detailOwner.dispose(); + detailGate.dispose(); + await database.close(); + }); + + testWidgets('U03 unknown read shows no false preference and retry recovers', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + detailPreferences.readBarrier = Completer(); + await _pumpMyPage(tester, settle: false, withNavigation: true); + final status = find.byKey(const Key('detailedNotificationStatus')); + expect(find.byKey(const Key('detailedNotificationSwitch')), findsNothing); + expect(tester.widget(status).data, 'Checking setting'); + expect(detailPreferences.writes, isEmpty); + await tester.ensureVisible(status); + await tester.pump(); + await captureRefresh(tester, 'u03-en-unknown'); + detailPreferences.failRead = true; + detailPreferences.readBarrier!.complete(); + await tester.pumpAndSettle(); + expect(find.textContaining('Unable to read the setting'), findsOneWidget); + expect(find.textContaining('Saved: off'), findsNothing); + detailPreferences.failRead = false; + await database.userDao.updateDetailedNotificationContent( + userId: localProfileId, + enabled: true, + ); + final retry = find.byKey(const Key('detailedNotificationRetry')); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const Key('detailedNotificationSwitch')), + ) + .value, + isTrue, + ); + expect(find.textContaining('Saved: on'), findsOneWidget); + expect(detailPreferences.writes, isEmpty); + } finally { + if (detailPreferences.readBarrier != null && + !detailPreferences.readBarrier!.isCompleted) { + detailPreferences.readBarrier!.complete(); + } + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } }); + for (final requested in [true, false]) { + testWidgets( + 'U03 failed ${requested ? 'ON' : 'OFF'} keeps saved truth and retries', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await database.userDao.updateDetailedNotificationContent( + userId: localProfileId, + enabled: !requested, + ); + await _pumpMyPage(tester, withNavigation: true); + detailPreferences.failWrite = true; + final toggle = find.byKey(const Key('detailedNotificationSwitch')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pumpAndSettle(); + final status = tester + .widget(find.byKey(const Key('detailedNotificationStatus'))) + .data!; + expect(status, contains(requested ? 'Saved: off' : 'Saved: on')); + expect( + status, + contains( + requested + ? 'On requested · not saved yet' + : 'Off requested · not saved yet', + ), + ); + expect(status, contains('could not be saved')); + expect( + status.contains('until the off request is resolved'), + !requested, + ); + expect( + detailOwner.allowsDetailed(detailOwner.captureContentPermit()), + false, + ); + expect(detailPreferences.writes, [requested]); + final retry = find.byKey(const Key('detailedNotificationRetry')); + final statusFinder = find.byKey( + const Key('detailedNotificationStatus'), + ); + await tester.ensureVisible(statusFinder); + await tester.pump(); + final statusRect = tester.getRect(statusFinder); + final navigation = tester + .widget(find.byType(Scaffold).first) + .bottomNavigationBar!; + expect( + statusRect.top, + greaterThanOrEqualTo(tester.getRect(find.byType(AppBar)).bottom), + ); + expect( + statusRect.bottom, + lessThanOrEqualTo(tester.getRect(find.byWidget(navigation)).top), + ); + await captureRefresh( + tester, + 'u03-en-failed-${requested ? 'on' : 'off'}', + ); + detailPreferences.failWrite = false; + await tester.ensureVisible(retry); + await tester.pump(); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(detailPreferences.writes, [requested, requested]); + expect( + (await database.userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + requested, + ); + expect(find.textContaining('not saved yet'), findsNothing); + expect(find.textContaining('could not be saved'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'U03 leaving and returning retains accepted OFF and failed retry', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await database.userDao.updateDetailedNotificationContent( + userId: localProfileId, + enabled: true, + ); + await _pumpMyPage(tester, withNavigation: true); + final controller = getIt(); + final router = GoRouter.of(tester.element(find.byType(MyPageScreen))); + detailPreferences.writeBarrier = Completer(); + detailPreferences.failWrite = true; + final toggle = find.byKey(const Key('detailedNotificationSwitch')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pump(); + expect(controller.state.requestedEnabled, false); + router.go('/myData'); + await tester.pumpAndSettle(); + expect(find.byType(MyPageScreen), findsNothing); + expect(controller.isClosed, false); + detailPreferences.writeBarrier!.complete(); + await _pumpUntil( + tester, + () => controller.state.save == DetailedPreferenceSave.failed, + ); + expect(controller.state.confirmedEnabled, true); + expect( + detailOwner.allowsDetailed(detailOwner.captureContentPermit()), + false, + ); + router.go('/myPage'); + await tester.pumpAndSettle(); + expect( + identical(getIt(), controller), + true, + ); + expect( + find.textContaining('Off requested · not saved yet'), + findsOneWidget, + ); + expect(find.textContaining('Saved: on'), findsOneWidget); + detailPreferences.failWrite = false; + final retry = find.byKey(const Key('detailedNotificationRetry')); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect( + (await database.userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + false, + ); + expect(controller.state.requestedEnabled, isNull); + expect(tester.takeException(), isNull); + } finally { + if (detailPreferences.writeBarrier != null && + !detailPreferences.writeBarrier!.isCompleted) { + detailPreferences.writeBarrier!.complete(); + } + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + }, + ); + + testWidgets( + 'U03 ten second delay keeps response pending and OFF database retry live', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await _pumpMyPage(tester, withNavigation: true); + final controller = getIt(); + reconcile.barrier = Completer(); + final toggle = find.byKey(const Key('detailedNotificationSwitch')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await _pumpUntil( + tester, + () => + controller.state.delivery == DetailedPreferenceDelivery.applying, + ); + final pendingCalls = reconcile.callCount; + await tester.pump(const Duration(seconds: 11)); + expect(controller.state.delivery, DetailedPreferenceDelivery.delayed); + expect( + find.textContaining('The device is taking longer to respond'), + findsOneWidget, + ); + await controller.retry(); + expect(reconcile.callCount, pendingCalls); + expect( + find.byKey(const Key('detailedNotificationRetry')), + findsNothing, + ); + await tester.ensureVisible( + find.byKey(const Key('detailedNotificationStatus')), + ); + await tester.pump(); + await captureRefresh(tester, 'u03-en-delayed'); + detailPreferences.failWrite = true; + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await _pumpUntil( + tester, + () => controller.state.save == DetailedPreferenceSave.failed, + ); + final retry = find.byKey(const Key('detailedNotificationRetry')); + expect(tester.widget(retry).onPressed, isNotNull); + detailPreferences.failWrite = false; + await tester.ensureVisible(retry); + await tester.tap(retry); + await _pumpUntil( + tester, + () => + controller.state.confirmedEnabled == false && + controller.state.save == DetailedPreferenceSave.idle, + ); + expect( + (await database.userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + false, + ); + expect(detailPreferences.writes, [true, false, false]); + expect(reconcile.barrier!.isCompleted, false); + reconcile.barrier!.complete(); + await tester.pumpAndSettle(); + expect( + controller.state.delivery, + DetailedPreferenceDelivery.noUpcoming, + ); + expect( + find.textContaining('The device is taking longer to respond'), + findsNothing, + ); + expect(tester.takeException(), isNull); + } finally { + if (reconcile.barrier != null && !reconcile.barrier!.isCompleted) { + reconcile.barrier!.complete(); + } + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + }, + ); + + for (final language in ['ko', 'en']) { + for (final scenario in ['saved', 'cleanup', 'read-error']) { + testWidgets('U03 $language $scenario remains accessible at 200 percent', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + try { + if (scenario == 'read-error') { + detailPreferences.failRead = true; + } + if (scenario == 'cleanup') { + reconcile.status = AlarmReconciliationStatus.partial; + reconcile.failures = const [ + AlarmFailure(reason: AlarmFailureReason.cancellationFailed), + ]; + } + await _pumpMyPage( + tester, + locale: Locale(language), + textScale: 2, + withNavigation: true, + ); + final destinations = + [ + find.byIcon(Icons.home_outlined), + find.byIcon(Icons.add_circle), + find.byIcon(Icons.person_outline).last, + ].map( + (icon) => find + .ancestor(of: icon, matching: find.byType(InkWell)) + .first, + ); + final destinationHeights = destinations + .map((item) => tester.getSize(item).height) + .toList(); + expect(destinationHeights, everyElement(greaterThanOrEqualTo(56))); + expect(destinationHeights.toSet(), hasLength(1)); + final status = find.byKey(const Key('detailedNotificationStatus')); + await tester.ensureVisible(status); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of(tester.element(status))!; + final text = tester.widget(status).data!; + expect( + text, + contains(switch (scenario) { + 'read-error' => l10n.detailedNotificationReadFailed, + 'cleanup' => l10n.detailedNotificationCancellation, + _ => l10n.detailedNotificationSavedOff, + }), + ); + expect( + tester.getSemantics(status).flagsCollection.isLiveRegion, + true, + ); + expect(tester.getSemantics(status).label, contains(text)); + if (scenario == 'read-error') { + expect( + find.byKey(const Key('detailedNotificationSwitch')), + findsNothing, + ); + expect(text, isNot(contains(l10n.detailedNotificationSavedOff))); + } + await captureRefresh(tester, 'u03-$language-$scenario-200'); + if (scenario != 'saved') { + final retry = find.byKey(const Key('detailedNotificationRetry')); + await tester.ensureVisible(retry); + await tester.pumpAndSettle(); + expect(tester.widget(retry).onPressed, isNotNull); + expect(tester.getRect(retry).bottom, lessThanOrEqualTo(800)); + await captureRefresh(tester, 'u03-$language-$scenario-retry-200'); + detailPreferences.failRead = false; + reconcile.status = AlarmReconciliationStatus.armed; + reconcile.failures = const []; + await tester.tap(retry); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('detailedNotificationRetry')), + findsNothing, + ); + } + expect(tester.takeException(), isNull); + } finally { + try { + await tester.pumpWidget(const SizedBox.shrink()); + } finally { + semantics.dispose(); + } + } + }); + } + } + testWidgets('Korean settings match the phone layout', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -95,7 +495,7 @@ void main() { expect(find.text('My Page'), findsOneWidget); expect(find.text('Back up my data'), findsOneWidget); - expect(find.text('알림에 일정 이름 표시'), findsOneWidget); + expect(find.text('Show schedule names in notifications'), findsOneWidget); expect(find.text('Sign in'), findsNothing); expect(find.textContaining('email'), findsNothing); expect(find.text('No scheduled notifications'), findsOneWidget); @@ -106,7 +506,7 @@ void main() { ) async { await _pumpMyPage(tester); - final detailSwitch = find.widgetWithText(SwitchListTile, '알림에 일정 이름 표시'); + final detailSwitch = find.byKey(const Key('detailedNotificationSwitch')); expect(tester.widget(detailSwitch).value, isFalse); await tester.tap(detailSwitch); @@ -126,7 +526,7 @@ void main() { (tester) async { await _pumpMyPage(tester); reconcile.fail = true; - final detail = find.widgetWithText(SwitchListTile, '알림에 일정 이름 표시'); + final detail = find.byKey(const Key('detailedNotificationSwitch')); await tester.tap(detail); await tester.pumpAndSettle(); expect( @@ -136,7 +536,10 @@ void main() { true, ); expect(tester.widget(detail).value, true); - expect(find.byType(SnackBar), findsOneWidget); + expect( + find.textContaining('Notification changes could not be confirmed'), + findsOneWidget, + ); expect(tester.takeException(), isNull); }, ); @@ -171,7 +574,8 @@ void main() { testWidgets( 'disabled delivery still shows unconfirmed cleanup and clears after recovery', (tester) async { - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + await alarmRepository.updateAlarmSettings(alarmsEnabled: false); + alarmRepository.updatedSettings.clear(); reconcile.disabledStatus = AlarmReconciliationStatus.partial; await _pumpMyPage(tester); expect(find.text('Off · cancellation needs checking'), findsOneWidget); @@ -187,7 +591,8 @@ void main() { testWidgets('fallback permission enables local schedule notifications', ( tester, ) async { - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + await alarmRepository.updateAlarmSettings(alarmsEnabled: false); + alarmRepository.updatedSettings.clear(); scheduler.capabilities = AlarmSchedulerCapabilities.unsupported; fallback.permission = AlarmPermissionState.granted; @@ -234,7 +639,8 @@ void main() { testWidgets( 'timing education is optional once and settings require explicit choice', (tester) async { - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + await alarmRepository.updateAlarmSettings(alarmsEnabled: false); + alarmRepository.updatedSettings.clear(); fallback.timingPermission = AlarmPermissionState.denied; await _pumpMyPage(tester); expect(fallback.timingRequestCount, 0); @@ -278,7 +684,8 @@ void main() { testWidgets( 'education setting choice rereads state without claiming a grant', (tester) async { - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + await alarmRepository.updateAlarmSettings(alarmsEnabled: false); + alarmRepository.updatedSettings.clear(); fallback.timingPermission = AlarmPermissionState.denied; await _pumpMyPage(tester); await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); @@ -454,13 +861,16 @@ Future _pumpMyPage( WidgetTester tester, { NotificationService? notificationService, Locale locale = const Locale('en'), + double textScale = 1, + bool settle = true, + bool? withNavigation, }) async { final router = GoRouter( initialLocation: '/myPage', routes: [ GoRoute( path: '/myPage', - builder: (_, _) => locale.languageCode == 'ko' + builder: (_, _) => (withNavigation ?? locale.languageCode == 'ko') ? BottomNavBarScaffold( child: MyPageScreen(notificationService: notificationService), ) @@ -487,9 +897,10 @@ Future _pumpMyPage( theme: themeData, debugShowCheckedModeBanner: false, builder: (context, child) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(padding: const EdgeInsets.only(top: 44, bottom: 34)), + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(top: 44, bottom: 34), + textScaler: TextScaler.linear(textScale), + ), child: child!, ), locale: locale, @@ -498,23 +909,50 @@ Future _pumpMyPage( routerConfig: router, ), ); - await tester.pumpAndSettle(); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } +} + +Future _pumpUntil(WidgetTester tester, bool Function() completed) async { + for (var attempt = 0; attempt < 100 && !completed(); attempt++) { + await tester.pump(const Duration(milliseconds: 10)); + } + expect(completed(), true, reason: 'Expected controlled operation to settle'); + // The state stream can emit after a pump without rebuilding its listener yet. + await tester.pump(); } class _FakeAlarmRepository implements AlarmRepository { + _FakeAlarmRepository(this.database); + + final AppDatabase database; AlarmSettings settings = const AlarmSettings(alarmsEnabled: true); final updatedSettings = []; @override - Future getAlarmSettings() async => settings; + Future getAlarmSettings() async { + final stored = await database.userDao.getAlarmSettings(localProfileId); + settings = AlarmSettings( + alarmsEnabled: stored.enabled, + defaultAlarmOffsetMinutes: stored.offsetMinutes, + detailedNotificationContent: stored.detailedNotificationContent, + ); + return settings; + } @override Future updateAlarmSettings({ required bool alarmsEnabled, }) async { updatedSettings.add(alarmsEnabled); - settings = AlarmSettings(alarmsEnabled: alarmsEnabled); - return settings; + await database.userDao.updateAlarmSettings( + userId: localProfileId, + enabled: alarmsEnabled, + ); + return getAlarmSettings(); } @override @@ -628,10 +1066,13 @@ class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { List armedIds = []; AlarmReconciliationStatus status = AlarmReconciliationStatus.armed; bool fail = false; + Completer? barrier; + List failures = const []; @override Future call() async { callCount += 1; + await barrier?.future; if (fail) throw const AlarmOperationInvalidated(); return AlarmReconciliationResult( status: (await repository.getAlarmSettings()).alarmsEnabled @@ -641,7 +1082,7 @@ class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { fallbackProvider: AlarmProvider.localNotification, armedScheduleIds: armedIds, skippedScheduleCount: 0, - failures: const [], + failures: failures, scheduleWindowStart: DateTime(2026), scheduleWindowEnd: DateTime(2027), alarmCoverageStart: DateTime(2026), @@ -705,3 +1146,35 @@ class _FakeNotificationService implements NotificationService { @override noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _ControlledPreferenceService + extends DetailedNotificationPreferenceService { + _ControlledPreferenceService(super.database, LocalDataOperationGate gate) + : super(gate: gate); + + bool failRead = false; + bool failWrite = false; + Completer? readBarrier; + Completer? writeBarrier; + final writes = []; + + @override + Future read({ + required int expectedGeneration, + }) async { + await readBarrier?.future; + if (failRead) throw StateError('Controlled unavailable preference'); + return super.read(expectedGeneration: expectedGeneration); + } + + @override + Future write( + bool enabled, { + required int expectedGeneration, + }) async { + writes.add(enabled); + await writeBarrier?.future; + if (failWrite) throw StateError('Controlled save failure'); + return super.write(enabled, expectedGeneration: expectedGeneration); + } +} diff --git a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart index e20c81fd..75cfcae7 100644 --- a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart +++ b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart @@ -1,398 +1,345 @@ import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_default_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_spare_time_use_case.dart'; +import 'package:on_time_front/domain/use-cases/default_preferences_workflow.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); +class TestPreferencesWorkflow extends Fake + implements DefaultPreferencesWorkflow { + PreparationEntity preparation = const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Shower', + preparationTime: Duration(minutes: 5), + ), + ], + ); + Duration spare = const Duration(minutes: 10); + @override + int generation = 0; + int saves = 0; + int retries = 0; + Future Function()? readHandler; + Future Function(DefaultPreferencesSubmission)? + saveHandler; + Future Function(DefaultPreferencesSaveReceipt)? + retryHandler; + DefaultPreferencesSubmission? submitted; + @override + bool isGenerationCurrent(int value) => value == generation; + @override + Future read() async => + await readHandler?.call() ?? snapshot; + DefaultPreferencesSnapshot get snapshot => DefaultPreferencesSnapshot( + preparation: preparation, + spareTime: spare, + store: 'store', + generation: generation, + revision: 1, + ); + DefaultPreferencesSaveReceipt receipt({ + bool reload = false, + bool delivery = false, + }) => DefaultPreferencesSaveReceipt( + operation: Object(), + store: 'store', + generation: generation, + changed: true, + revision: 2, + reloadPending: reload, + deliveryPending: delivery, + ); + @override + Future save( + DefaultPreferencesSubmission input, + ) async { + saves++; + submitted = input; + return await saveHandler?.call(input) ?? receipt(); + } - late _FakePreparationStore preparationStore; + @override + Future retry( + DefaultPreferencesSaveReceipt value, + ) async { + retries++; + return await retryHandler?.call(value) ?? + value.withFollowUp(reloadPending: false, deliveryPending: false); + } +} +void main() { + late TestPreferencesWorkflow workflow; setUp(() async { await getIt.reset(); - preparationStore = _FakePreparationStore( - const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'step-1', - preparationName: 'Shower', - preparationTime: Duration(minutes: 5), - ), - ], - ), + workflow = TestPreferencesWorkflow(); + getIt.registerFactory( + () => DefaultPreparationSpareTimeFormBloc(workflow), ); - - getIt - ..registerFactory( - () => DefaultPreparationSpareTimeFormBloc( - _FakeGetDefaultPreparationUseCase(preparationStore), - _FakeUpdateDefaultPreparationUseCase(preparationStore), - _FakeUpdateSpareTimeUseCase(preparationStore), - _FakeLoadUserUseCase(), - ), - ) - ..registerFactory(PreparationFormBloc.new); + getIt.registerFactory(PreparationFormBloc.new); }); - tearDown(() async { await getIt.reset(); }); + Future pump( + WidgetTester tester, { + String locale = 'ko', + double scale = 1, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PreparationSpareTimeEditScreen(), + ), + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } - testWidgets('keeps preparation edits mounted when spare time changes', ( - tester, - ) async { - await _pumpScreen(tester); - - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - - await tester.enterText(find.byType(TextFormField).first, 'Coffee'); - await tester.pump(); - - await tester.tap(find.byIcon(Icons.add).first); - await tester.pump(); - - expect(find.text('Coffee'), findsOneWidget); - expect(find.text('15분'), findsOneWidget); - }); - - testWidgets('save failure keeps editor open and shows error', (tester) async { - preparationStore.updateDefaultHandler = (_) async { - throw Exception('save failed'); - }; - - await _pumpScreen(tester); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - + Future save(WidgetTester tester) async { await tester.tap(find.byType(TextButton).first); await tester.pumpAndSettle(); + } - expect(find.byType(PreparationSpareTimeEditScreen), findsOneWidget); - expect(find.byType(TwoActionDialog), findsOneWidget); - expect(find.textContaining('save failed'), findsOneWidget); - }); - - testWidgets('save waits for completion before navigating back', ( + testWidgets( + 'coherent initial values and edits remain mounted across spare changes', + (tester) async { + workflow.spare = const Duration(minutes: 3); + await pump(tester); + expect(find.text('3분'), findsOneWidget); + await tester.enterText(find.byType(TextFormField).first, 'Coffee'); + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + expect(find.text('Coffee'), findsOneWidget); + expect(find.text('8분'), findsOneWidget); + }, + ); + testWidgets( + 'safe save failure preserves edited preparation without raw exception', + (tester) async { + workflow.saveHandler = (_) async => + throw StateError('/private/path secret'); + await pump(tester); + await tester.enterText(find.byType(TextFormField).first, 'Coffee'); + await save(tester); + expect(find.byType(PreparationSpareTimeEditScreen), findsOneWidget); + expect(find.text('Coffee'), findsOneWidget); + expect(find.textContaining('저장하지 못했어요'), findsOneWidget); + expect(find.textContaining('/private/path'), findsNothing); + }, + ); + testWidgets('save waits and repeated taps cannot submit twice', ( tester, ) async { - final completer = Completer(); - preparationStore.updateDefaultHandler = (_) => completer.future; - - await _pumpRoutedScreen(tester); - await tester.tap(find.text('open')); - await tester.pumpAndSettle(); - + final pending = Completer(); + workflow.saveHandler = (_) => pending.future; + await pump(tester); await tester.tap(find.byType(TextButton).first); await tester.pump(); - + await tester.tap(find.byType(TextButton).first); + await tester.pump(); + expect(workflow.saves, 1); expect(find.byType(PreparationSpareTimeEditScreen), findsOneWidget); - - completer.complete(); + pending.complete(workflow.receipt()); await tester.pumpAndSettle(); - - expect(find.byType(PreparationSpareTimeEditScreen), findsNothing); expect(find.text('open'), findsOneWidget); }); - - testWidgets('changed preparation step time is submitted', (tester) async { - await _pumpScreen(tester); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - - final context = tester.element(find.byType(TextFormField).first); - context.read().add( - const PreparationFormPreparationStepTimeChanged( - index: 0, - preparationStepTime: Duration(minutes: 15), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(TextButton).first); + testWidgets('changed step time is submitted through the concrete workflow', ( + tester, + ) async { + await pump(tester); + tester + .element(find.byType(TextFormField).first) + .read() + .add( + const PreparationFormPreparationStepTimeChanged( + index: 0, + preparationStepTime: Duration(minutes: 15), + ), + ); await tester.pumpAndSettle(); - + await save(tester); expect( - preparationStore - .updatedPreparation! - .preparationStepList - .single - .preparationTime, + workflow.submitted!.preparation.totalDuration, const Duration(minutes: 15), ); }); - - test( - 'form bloc loads defaults and edits spare time in five-minute steps', - () async { - final bloc = _buildBloc(preparationStore); - addTearDown(bloc.close); - - bloc.add(const FormEditRequested(spareTime: Duration(minutes: 15))); - await expectLater( - bloc.stream, - emitsInOrder([ - isA().having( - (state) => state.status, - 'status', - DefaultPreparationSpareTimeStatus.loading, - ), - isA() - .having( - (state) => state.status, - 'status', - DefaultPreparationSpareTimeStatus.success, - ) - .having( - (state) => state.spareTime, - 'spareTime', - const Duration(minutes: 15), - ), - ]), + testWidgets('partial save locks input and retries only its receipt', ( + tester, + ) async { + workflow.saveHandler = (_) async => workflow.receipt(reload: true); + await pump(tester); + await save(tester); + expect(find.textContaining('설정은 저장되었지만'), findsOneWidget); + await tester.tap(find.byIcon(Icons.add).first, warnIfMissed: false); + await tester.pump(); + expect(find.text('10분'), findsOneWidget); + await tester.tap(find.text('남은 작업 다시 시도')); + await tester.pumpAndSettle(); + expect(workflow.saves, 1); + expect(workflow.retries, 1); + expect(find.text('open'), findsOneWidget); + }); + testWidgets( + 'authority verification failure stays saved and exposes a safe retry', + (tester) async { + workflow.saveHandler = (_) async => workflow.receipt().withFollowUp( + reloadPending: false, + deliveryPending: false, + authorityPending: true, ); - - bloc - ..add(const SpareTimeIncreased()) - ..add(const SpareTimeDecreased()) - ..add(const SpareTimeDecreased()) - ..add(const SpareTimeDecreased()); - await testerPumpEventQueue(); - - expect(bloc.state.spareTime, const Duration(minutes: 10)); + await pump(tester); + await save(tester); + expect(find.textContaining('현재 로컬 데이터를 확인하지 못했어요'), findsOneWidget); + expect(find.textContaining('교체되었어요'), findsNothing); + await tester.tap(find.text('남은 작업 다시 시도')); + await tester.pumpAndSettle(); + expect(workflow.saves, 1); + expect(workflow.retries, 1); + expect(find.text('open'), findsOneWidget); }, ); - test( - 'form bloc persists preparation and spare time before reloading user', - () async { - final bloc = _buildBloc(preparationStore); - addTearDown(bloc.close); - const editedPreparation = PreparationEntity( + testWidgets( + 'stale draft reload cancellation and failed reload preserve input', + (tester) async { + workflow.saveHandler = (_) async => + throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.conflict, + ); + await pump(tester); + await tester.enterText(find.byType(TextFormField).first, 'Coffee'); + await save(tester); + await tester.tap(find.text('최신 설정 다시 불러오기')); + await tester.pumpAndSettle(); + expect(find.byType(TwoActionDialog), findsOneWidget); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(find.text('Coffee'), findsOneWidget); + workflow.readHandler = () async => throw StateError('private'); + await tester.tap(find.text('최신 설정 다시 불러오기')); + await tester.pumpAndSettle(); + await tester.tap(find.text('최신 설정 다시 불러오기').last); + await tester.pumpAndSettle(); + expect(find.text('Coffee'), findsOneWidget); + expect(find.textContaining('일정 또는 설정'), findsOneWidget); + workflow.readHandler = null; + workflow.preparation = const PreparationEntity( preparationStepList: [ PreparationStepEntity( - id: 'step-2', - preparationName: 'Pack bag', + id: 'new', + preparationName: 'Latest', preparationTime: Duration(minutes: 8), ), ], ); - - bloc.add(const FormEditRequested(spareTime: Duration(minutes: 20))); - await bloc.stream.firstWhere( - (state) => state.status == DefaultPreparationSpareTimeStatus.success, - ); - bloc.add( - const FormSubmitted( - note: 'Updated note', - preparation: editedPreparation, - ), - ); - await bloc.stream.firstWhere( - (state) => state.status == DefaultPreparationSpareTimeStatus.submitted, - ); - - expect(preparationStore.updatedPreparation, editedPreparation); - expect(preparationStore.updatedSpareTime, const Duration(minutes: 20)); - expect(preparationStore.loadUserCount, 1); + await tester.tap(find.text('최신 설정 다시 불러오기')); + await tester.pumpAndSettle(); + await tester.tap(find.text('최신 설정 다시 불러오기').last); + await tester.pumpAndSettle(); + expect(find.text('Latest'), findsOneWidget); + expect(find.text('Coffee'), findsNothing); }, ); - - test( - 'form bloc reports errors when spare time is absent or update fails', - () async { - final missingSpareBloc = _buildBloc(preparationStore); - addTearDown(missingSpareBloc.close); - - missingSpareBloc.add( - const FormSubmitted( - note: '', - preparation: PreparationEntity(preparationStepList: []), - ), - ); - await missingSpareBloc.stream.firstWhere( - (state) => state.status == DefaultPreparationSpareTimeStatus.error, - ); - expect(preparationStore.updatedPreparation, isNull); - - final failingStore = - _FakePreparationStore(preparationStore.defaultPreparation) - ..updateDefaultHandler = (_) async { - throw Exception('update failed'); - }; - final failingBloc = _buildBloc(failingStore); - addTearDown(failingBloc.close); - - failingBloc.add( - const FormEditRequested(spareTime: Duration(minutes: 20)), - ); - await failingBloc.stream.firstWhere( - (state) => state.status == DefaultPreparationSpareTimeStatus.success, + testWidgets('initial load failure has a real retry and no endless spinner', ( + tester, + ) async { + workflow.readHandler = () async => throw StateError('hidden'); + await pump(tester); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.textContaining('불러오지 못했어요'), findsOneWidget); + workflow.readHandler = null; + await tester.tap(find.text('최신 설정 다시 불러오기')); + await tester.pumpAndSettle(); + expect(find.text('Shower'), findsOneWidget); + }); + testWidgets( + 'disposed route cannot pop a later route on old save completion', + (tester) async { + final pending = Completer(); + workflow.saveHandler = (_) => pending.future; + await pump(tester); + await tester.tap(find.byType(TextButton).first); + await tester.pump(); + final navigator = Navigator.of( + tester.element(find.byType(PreparationSpareTimeEditScreen)), ); - failingBloc.add( - const FormSubmitted( - note: '', - preparation: PreparationEntity(preparationStepList: []), + navigator.pop(); + await tester.pumpAndSettle(); + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('later route')), ), ); - - await failingBloc.stream.firstWhere( - (state) => state.status == DefaultPreparationSpareTimeStatus.error, - ); + await tester.pumpAndSettle(); + pending.complete(workflow.receipt()); + await tester.pumpAndSettle(); + expect(find.text('later route'), findsOneWidget); + expect(tester.takeException(), isNull); }, ); -} - -Future testerPumpEventQueue() async { - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); -} - -DefaultPreparationSpareTimeFormBloc _buildBloc(_FakePreparationStore store) { - return DefaultPreparationSpareTimeFormBloc( - _FakeGetDefaultPreparationUseCase(store), - _FakeUpdateDefaultPreparationUseCase(store), - _FakeUpdateSpareTimeUseCase(store), - _FakeLoadUserUseCase(store), - ); -} - -Future _pumpScreen(WidgetTester tester) async { - await tester.pumpWidget( - BlocProvider.value( - value: _StubAuthBloc(), - child: MaterialApp( - theme: themeData, - locale: const Locale('ko'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const PreparationSpareTimeEditScreen(), - ), - ), - ); -} - -Future _pumpRoutedScreen(WidgetTester tester) async { - await tester.pumpWidget( - BlocProvider.value( - value: _StubAuthBloc(), - child: MaterialApp( - theme: themeData, - locale: const Locale('ko'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Builder( - builder: (context) { - return TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const PreparationSpareTimeEditScreen(), - ), - ); - }, - child: const Text('open'), - ); - }, - ), - ), - ), + testWidgets( + 'restore during save keeps superseded receipt from auto popping', + (tester) async { + final pending = Completer(); + workflow.saveHandler = (_) => pending.future; + await pump(tester); + await tester.tap(find.byType(TextButton).first); + await tester.pump(); + final old = workflow.receipt(); + workflow.generation++; + pending.complete(old); + await tester.pumpAndSettle(); + expect(find.byType(PreparationSpareTimeEditScreen), findsOneWidget); + expect(find.textContaining('로컬 작업 상태가 달라'), findsOneWidget); + }, ); -} - -class _FakePreparationStore { - _FakePreparationStore(this.defaultPreparation); - - PreparationEntity defaultPreparation; - PreparationEntity? updatedPreparation; - Duration? updatedSpareTime; - Future Function(PreparationEntity preparationEntity)? - updateDefaultHandler; - int loadUserCount = 0; -} - -class _FakeGetDefaultPreparationUseCase extends Mock - implements GetDefaultPreparationUseCase { - _FakeGetDefaultPreparationUseCase(this.store); - - final _FakePreparationStore store; - - @override - Future call() async => store.defaultPreparation; -} - -class _FakeUpdateDefaultPreparationUseCase extends Mock - implements UpdateDefaultPreparationUseCase { - _FakeUpdateDefaultPreparationUseCase(this.store); - - final _FakePreparationStore store; - - @override - Future call(PreparationEntity preparationEntity) async { - final handler = store.updateDefaultHandler; - if (handler != null) { - await handler(preparationEntity); - return; - } - store.updatedPreparation = preparationEntity; - } -} - -class _FakeUpdateSpareTimeUseCase extends Mock - implements UpdateSpareTimeUseCase { - _FakeUpdateSpareTimeUseCase(this.store); - - final _FakePreparationStore store; - - @override - Future call(Duration newSpareTime) async { - store.updatedSpareTime = newSpareTime; - } -} - -class _FakeLoadUserUseCase extends Mock implements LoadUserUseCase { - _FakeLoadUserUseCase([this.store]); - - final _FakePreparationStore? store; - - @override - Future call() async { - store?.loadUserCount++; + for (final locale in ['ko', 'en']) { + testWidgets('$locale 200 percent pending and conflict states fit', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + workflow.saveHandler = (_) async => + workflow.receipt(reload: true, delivery: true); + await pump(tester, locale: locale, scale: 2); + await save(tester); + expect( + find.byKey(const ValueKey('default_preferences_notice')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); } } - -class _StubAuthBloc extends Mock implements AuthBloc { - @override - AuthState get state => AuthState( - user: const UserEntity( - id: 'user-1', - spareTime: Duration(minutes: 10), - note: '', - eligibleOutcomeCount: 0, - onTimeOutcomeCount: 0, - isOnboardingCompleted: true, - ), - ); - - @override - Stream get stream => const Stream.empty(); - - @override - bool get isClosed => false; -} diff --git a/test/presentation/recurring/recurrence_settings_sheet_test.dart b/test/presentation/recurring/recurrence_settings_sheet_test.dart new file mode 100644 index 00000000..26355be2 --- /dev/null +++ b/test/presentation/recurring/recurrence_settings_sheet_test.dart @@ -0,0 +1,223 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_settings_sheet.dart'; + +class _Result { + bool completed = false; + RecurrenceSettingsResult? value; +} + +Future<_Result> _open(WidgetTester tester, RecurrenceRule rule) async { + final result = _Result(); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result.value = await Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => RecurrenceSettingsSheet( + start: rule.start, + timeZoneId: rule.timeZoneId, + initial: rule, + ), + ), + ); + result.completed = true; + }, + child: const Text('Open recurrence'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open recurrence')); + await tester.pumpAndSettle(); + return result; +} + +Future _ending(WidgetTester tester) async { + final setting = find.byWidgetPredicate( + (widget) => widget is RecurrenceValue && widget.label == 'Repeat ends', + ); + await tester.ensureVisible(setting); + await tester.tap(setting); + await tester.pumpAndSettle(); + await tester.tap(find.text('On date')); + await tester.pumpAndSettle(); +} + +Future _datePicker(WidgetTester tester) async { + final date = find.text('Inclusive end date'); + await tester.ensureVisible(date); + await tester.tap(date); + await tester.pumpAndSettle(); +} + +Future _select(WidgetTester tester, String wheel, int index) async { + final picker = tester.widget( + find.byKey(ValueKey('civil-picker-$wheel')), + ); + picker.scrollController!.jumpToItem(index); + await tester.pumpAndSettle(); +} + +Future _confirm(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey('civil-picker-confirm'))); + await tester.pumpAndSettle(); +} + +Future _applyEnding(WidgetTester tester) async { + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); +} + +RecurrenceRule _daily(DateTime start, {DateTime? until}) => RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'UTC', + until: until, +); + +void main() { + testWidgets('default inclusive end remains December 30 on an Apia device', ( + tester, + ) async { + if (Platform.environment['TZ'] == 'Pacific/Apia') { + expect( + DateTime(2011, 12, 30).day, + isNot(30), + reason: 'Exercise the actual skipped date in the device timezone', + ); + } + final result = await _open(tester, _daily(DateTime.utc(2011, 9, 30, 9))); + await _ending(tester); + expect(find.text('Dec 30, 2011'), findsOneWidget); + expect(result.completed, isFalse); + await _applyEnding(tester); + expect(result.value!.rule!.until, DateTime.utc(2011, 12, 30)); + expect(result.value!.rule!.start, DateTime.utc(2011, 9, 30, 9)); + }); + + testWidgets( + 'default end near year 9999 stops at the last supported civil date', + (tester) async { + final result = await _open(tester, _daily(DateTime.utc(9999, 11, 30, 9))); + await _ending(tester); + expect(find.text('Dec 31, 9999'), findsOneWidget); + await _datePicker(tester); + expect(tester.takeException(), isNull); + await _confirm(tester); + await _applyEnding(tester); + expect(result.value!.rule!.until, DateTime.utc(9999, 12, 31)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'preview never proposes an occurrence after supported year 9999', + (tester) async { + final result = await _open( + tester, + RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: DateTime.utc(9999, 12, 31, 9), + timeZoneId: 'UTC', + weekdays: {DateTime.saturday}, + ), + ); + expect(find.textContaining('No matching occurrence.'), findsOneWidget); + expect(find.textContaining('10000'), findsNothing); + expect(result.completed, isFalse); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'date selection preserves skipped device date and inclusive calendar semantics', + (tester) async { + final start = DateTime.utc(2011, 12, 29, 23, 30, 12, 345, 678); + final result = await _open( + tester, + _daily(start, until: DateTime.utc(2011, 12, 29)), + ); + await _ending(tester); + await _datePicker(tester); + await _select(tester, 'day', 29); + expect(result.completed, isFalse); + await _confirm(tester); + expect(find.text('Dec 30, 2011'), findsOneWidget); + await _applyEnding(tester); + expect(result.value!.rule!.until, DateTime.utc(2011, 12, 30)); + expect(result.value!.rule!.start, start); + expect(result.value!.countChanged, isTrue); + }, + ); + + testWidgets( + 'cancelling the date picker keeps the previous end and unchanged count flag', + (tester) async { + final until = DateTime.utc(2030, 2, 20); + final result = await _open( + tester, + _daily(DateTime.utc(2030, 2, 10, 9), until: until), + ); + await _ending(tester); + await _datePicker(tester); + await _select(tester, 'day', 24); + await tester.tap(find.byKey(const ValueKey('civil-picker-cancel'))); + await tester.pumpAndSettle(); + expect(find.text('Feb 20, 2030'), findsOneWidget); + await _applyEnding(tester); + expect(result.value!.rule!.until, until); + expect(result.value!.countChanged, isFalse); + }, + ); + + testWidgets( + 'an end before the start shows an error and cannot replace the valid end', + (tester) async { + final until = DateTime.utc(2030, 2, 20); + final result = await _open( + tester, + _daily(DateTime.utc(2030, 2, 10, 9), until: until), + ); + await _ending(tester); + await _datePicker(tester); + await _select(tester, 'day', 8); + await _confirm(tester); + await tester.ensureVisible( + find.text('The end date cannot precede the start date.'), + ); + expect( + find.text('The end date cannot precede the start date.'), + findsOneWidget, + ); + expect(find.text('Feb 20, 2030'), findsOneWidget); + expect(result.completed, isFalse); + // Selecting a valid date after the rejection clears the validation error. + await _datePicker(tester); + await _select(tester, 'day', 21); + await _confirm(tester); + expect( + find.text('The end date cannot precede the start date.'), + findsNothing, + ); + await _applyEnding(tester); + expect(result.value!.rule!.until, DateTime.utc(2030, 2, 22)); + }, + ); +} diff --git a/test/presentation/recurring/recurrence_sheet_title_accessibility_test.dart b/test/presentation/recurring/recurrence_sheet_title_accessibility_test.dart new file mode 100644 index 00000000..bf258e61 --- /dev/null +++ b/test/presentation/recurring/recurrence_sheet_title_accessibility_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; + +void main() { + for (final entry in const { + 'ko': '겹치는 일정 확인', + 'en': 'Conflicting occurrences', + }.entries) { + testWidgets( + '${entry.key} recurrence title paints its full height at320px text2.5', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + locale: Locale(entry.key), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2.5)), + child: child!, + ), + home: Scaffold( + body: RecurrenceSheet( + title: entry.value, + onBack: () {}, + children: const [Text('Synthetic review body')], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final title = find.text(entry.value); + final element = tester.element(title); + final text = tester.widget(title); + final painter = TextPainter( + text: TextSpan( + text: entry.value, + style: DefaultTextStyle.of(element).style.merge(text.style), + ), + textDirection: Directionality.of(element), + textScaler: MediaQuery.textScalerOf(element), + locale: Localizations.localeOf(element), + )..layout(maxWidth: tester.getSize(title).width); + final needed = painter.height; + painter.dispose(); + expect( + tester.getSize(title).height, + greaterThanOrEqualTo(needed - 0.1), + reason: + 'A clipped title can silently hide lines without a RenderFlex exception.', + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } +} diff --git a/test/presentation/recurring/recurring_form_integration_test.dart b/test/presentation/recurring/recurring_form_integration_test.dart index b49d7fd2..f5db4343 100644 --- a/test/presentation/recurring/recurring_form_integration_test.dart +++ b/test/presentation/recurring/recurring_form_integration_test.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/shared/components/step_progress.dart'; import 'dart:io'; @@ -101,7 +102,7 @@ void main() { (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, ); Future save() => event( - const ScheduleFormCreated(confirmed: true), + ScheduleFormRecurrenceReviewConfirmed(bloc.state.recurrenceReview!), (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, ); @@ -132,7 +133,12 @@ void main() { recurringScheduleRepository: recurring, ); alarms = _AlarmEffects(); - final useCase = RecurringSchedulesUseCase(recurring, schedules, alarms); + final useCase = RecurringSchedulesUseCase( + recurring, + schedules, + alarms, + _UnusedDeletion(), + ); final aggregate = ScheduleAggregateRepositoryImpl( db, recurring, @@ -155,6 +161,7 @@ void main() { UpdateScheduleFormSubmissionUseCase(workflow), aggregate: aggregate, recurringSchedules: useCase, + now: () => now, ); }); tearDown(() async { @@ -371,7 +378,7 @@ void main() { '개별 변경', ); await event( - const ScheduleFormUpdated(confirmed: true), + ScheduleFormRecurrenceReviewConfirmed(bloc.state.recurrenceReview!), (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, ); final after = await schedules.getSchedulesByDate( @@ -518,11 +525,71 @@ void main() { tester.platformDispatcher.textScaleFactorTestValue = 2; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await tester.pump(); + final submittedMutation = bloc.state.mutationId; + final observedStatuses = []; + final observation = bloc.stream + .map((state) => state.submissionStatus) + .distinct() + .listen(observedStatuses.add); + addTearDown(observation.cancel); + final submittedOwner = bloc.formOwner; + final timeReviewed = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + await tester.tap(find.text(language == 'ko' ? '저장' : 'Save')); + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync( + () => timeReviewed.timeout(const Duration(seconds: 5)), + ); + await tester.pumpAndSettle(); + expect( + bloc.state.submissionStatus, + ScheduleFormSubmissionStatus.timeReview, + ); + final confirm = find.byKey( + const ValueKey('schedule-time-review-confirm'), + ); + void describeConfirmation(String phase) { + final elements = confirm.evaluate().toList(); + final form = find.byType(ScheduleMultiPageForm).evaluate().toList(); + debugPrint( + 'U02 $language $phase: status=${bloc.state.submissionStatus} ' + 'keyCount=${elements.length} hitCount=${confirm.hitTestable().evaluate().length} ' + 'view=${tester.view.physicalSize} insetsBottom=${tester.view.viewInsets.bottom} ' + 'alertCount=${find.byType(AlertDialog).evaluate().length} ' + 'formRouteCurrent=${form.isEmpty ? null : ModalRoute.of(form.single)?.isCurrent} ' + 'reviewNull=${bloc.state.timeReview == null} ' + 'ownerSame=${identical(submittedOwner, bloc.formOwner)} ' + 'mutationSame=${submittedMutation == bloc.state.mutationId} ' + 'statuses=$observedStatuses ' + 'rect=${elements.isEmpty ? null : tester.getRect(confirm)} ' + 'routeCurrent=${elements.isEmpty ? null : ModalRoute.of(elements.single)?.isCurrent}', + ); + } + + describeConfirmation('after explicit review stream and settle'); + // A Bloc event may finish after pumpAndSettle saw no scheduled frame. + // Give its listener and the dialog route bounded frames to render. + for ( + var frame = 0; + frame < 20 && confirm.hitTestable().evaluate().isEmpty; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 20)); + } + describeConfirmation('after bounded dialog frames'); + expect(confirm, findsOneWidget); + expect(confirm.hitTestable(), findsOneWidget); + expect( + await tester.runAsync(() => db.select(db.schedules).get()), + isEmpty, + ); final failed = bloc.stream.firstWhere( (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, ); - await tester.tap(find.text(language == 'ko' ? '저장' : 'Save')); - await tester.runAsync(() => failed); + await tester.tap(confirm); + await tester.pump(const Duration(milliseconds: 250)); + await tester.runAsync(() => failed.timeout(const Duration(seconds: 5))); await tester.pumpAndSettle(); expect( await tester.runAsync(() => db.select(db.schedules).get()), @@ -540,7 +607,8 @@ void main() { await tester.tap( find.text(language == 'ko' ? '다시 저장하기' : 'Retry saving'), ); - await tester.runAsync(() => pending); + await tester.pump(const Duration(milliseconds: 250)); + await tester.runAsync(() => pending.timeout(const Duration(seconds: 5))); await tester.pumpAndSettle(); expect( await tester.runAsync(() => db.select(db.schedules).get()), @@ -553,6 +621,18 @@ void main() { before! + 1, ); expect(saved, isNull); + expect( + observedStatuses.where( + (s) => s == ScheduleFormSubmissionStatus.timeReview, + ), + hasLength(1), + ); + expect( + find.byKey(const ValueKey('schedule-time-review-confirm')), + findsNothing, + ); + expect(bloc.state.saveReceipt!.mutationId, submittedMutation); + final pendingReceipt = bloc.state.saveReceipt!; await captureA08(tester, '$language-delivery-pending'); alarms.deliveryComplete = true; final done = bloc.stream.firstWhere( @@ -560,9 +640,19 @@ void main() { ); final l = AppLocalizations.of(tester.element(find.byType(AlertDialog)))!; await tester.tap(find.text(l.dataRetry)); - await tester.runAsync(() => done); + await tester.pump(const Duration(milliseconds: 250)); + await tester.runAsync(() => done.timeout(const Duration(seconds: 5))); await tester.pumpAndSettle(); expect(saved, isTrue); + expect( + observedStatuses.where( + (s) => s == ScheduleFormSubmissionStatus.timeReview, + ), + hasLength(1), + ); + expect(bloc.state.saveReceipt!.mutationId, pendingReceipt.mutationId); + expect(bloc.state.saveReceipt!.generation, pendingReceipt.generation); + expect(bloc.state.saveReceipt!.scheduleId, pendingReceipt.scheduleId); expect( await tester.runAsync(() => db.select(db.schedules).get()), hasLength(1), @@ -636,10 +726,27 @@ void main() { s.submissionStatus == (modal == 'review' ? ScheduleFormSubmissionStatus.review - : ScheduleFormSubmissionStatus.deliveryPending), + : ScheduleFormSubmissionStatus.timeReview), ), ); await tester.pumpAndSettle(); + if (modal == 'receipt') { + final pending = bloc.stream.firstWhere( + (s) => + s.submissionStatus == + ScheduleFormSubmissionStatus.deliveryPending, + ); + final confirm = find.byKey( + const ValueKey('schedule-time-review-confirm'), + ); + expect(confirm.hitTestable(), findsOneWidget); + await tester.tap(confirm); + await tester.pumpAndSettle(); + await tester.runAsync( + () => pending.timeout(const Duration(seconds: 5)), + ); + await tester.pumpAndSettle(); + } final previousOwner = bloc.formOwner; await tester.runAsync(draft); await tester.pump(); @@ -676,6 +783,10 @@ void main() { alarms.deliveryComplete = false; await event( const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + await event( + ScheduleFormTimeReviewConfirmed(bloc.state.timeReview!), (s) => s.submissionStatus == ScheduleFormSubmissionStatus.deliveryPending, ); @@ -692,6 +803,10 @@ void main() { alarms.deliveryComplete = true; await event( const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + await event( + ScheduleFormTimeReviewConfirmed(bloc.state.timeReview!), (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, ); expect(await db.select(db.schedules).get(), hasLength(2)); @@ -746,3 +861,5 @@ Future captureA08(WidgetTester tester, String name) async { image.dispose(); }); } + +class _UnusedDeletion extends Fake implements DeleteScheduleUseCase {} diff --git a/test/presentation/recurring/recurring_screens_test.dart b/test/presentation/recurring/recurring_screens_test.dart index 15540a6a..32306bcc 100644 --- a/test/presentation/recurring/recurring_screens_test.dart +++ b/test/presentation/recurring/recurring_screens_test.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -193,9 +196,11 @@ void main() { await tester.tap(find.text('반복 종료')); await tester.pumpAndSettle(); expect(useCase.deleted, isNull); - await tester.tap(find.text('반복 종료').last); + await tester.tap(find.widgetWithText(TextButton, '약속 삭제')); await tester.pumpAndSettle(); expect(useCase.deleted, RecurringEditScope.following); + await tester.tap(find.widgetWithText(TextButton, '확인')); + await tester.pumpAndSettle(); expect(find.text('예정된 회차 없음'), findsOneWidget); expect(tester.takeException(), isNull); }, @@ -389,16 +394,64 @@ class _Management implements RecurringSchedulesUseCase { List summaries; RecurringEditScope? deleted; @override + DeleteScheduleUseCase get deletions => _ManagementDeletion(this); + @override Future> list() async => summaries; @override - Future delete( + Future delete( ScheduleEntity occurrence, RecurringEditScope scope, ) async { deleted = scope; summaries = [RecurringScheduleSummary(summaries.first.segment, null)]; + return ScheduleDeletionResult( + commit: ScheduleDeletionCommit( + scheduleId: occurrence.id, + store: 'ui', + generation: 0, + removedIds: {occurrence.id}, + changed: true, + alreadyAbsent: false, + ), + cleanup: ScheduleDeletionCleanup.complete, + ); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _ManagementDeletion extends Fake implements DeleteScheduleUseCase { + _ManagementDeletion(this.management); + final _Management management; + @override + int get currentGeneration => 0; + @override + bool isCurrentGeneration(int generation) => generation == 0; + @override + Future prepare( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) async => ScheduleDeletionIntent( + intentId: 'ui', + scope: scope, + targets: [], + snapshot: ScheduleEditSnapshot( + management.summaries.first.next!, + _prep, + const ScheduleEditBaseline(store: 'ui', generation: 0, revision: 0), + ), + ); + @override + Future confirm( + ScheduleDeletionIntent intent, { + void Function(ScheduleDeletionCommit)? onCommitted, + }) async { + final result = await management.delete( + intent.snapshot.schedule, + intent.scope, + ); + onCommitted?.call(result.commit); + return result; + } +} diff --git a/test/presentation/recurring/recurring_time_correction_plan_summary_test.dart b/test/presentation/recurring/recurring_time_correction_plan_summary_test.dart new file mode 100644 index 00000000..45024831 --- /dev/null +++ b/test/presentation/recurring/recurring_time_correction_plan_summary_test.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurring_time_correction_plan_summary.dart'; + +ScheduleEntity _schedule(String id, {bool override = false}) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Appointment $id', + scheduleTime: DateTime.utc(2030, 1, 3, override ? 11 : 9), + timeZoneId: override ? 'Asia/Seoul' : 'UTC', + occurrenceOffsetSeconds: override ? 32400 : 0, + isChanged: false, + isStarted: false, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + recurringSegmentId: 'old-segment', + recurringOrdinal: 3, + recurringSlotKey: DateTime.utc(2030, 1, 3, 9).toIso8601String(), + recurringOverrides: override ? 'time' : '', +); + +RecurrenceSlot _slot(int ordinal) => RecurrenceSlot( + civilTime: DateTime.utc(2030, 1, ordinal, 9), + instantUtc: DateTime.utc(2030, 1, ordinal, 9), + offsetSeconds: 0, + ordinal: ordinal, +); + +RecurringTimeCorrectionMapping _mapping({int? many}) => + RecurringTimeCorrectionMapping( + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: DateTime.utc(2030, 1, 1, 9), + timeZoneId: 'UTC', + count: many ?? 5, + ), + automaticallyRetainedCount: true, + rows: many != null + ? [ + for (var i = 1; i <= many; i++) + TimeCorrectionRowMapping(_schedule('row-$i'), _slot(i)), + ] + : [ + TimeCorrectionRowMapping(_schedule('mapped'), _slot(2)), + TimeCorrectionRowMapping( + _schedule('override', override: true), + _slot(3), + ), + TimeCorrectionRowMapping(_schedule('detached'), null), + ], + protectedRows: many != null + ? [] + : [ + _schedule( + 'protected', + ).copyWith(doneStatus: ScheduleDoneStatus.normalEnd), + ], + exclusions: many != null + ? [] + : [ + TimeCorrectionExclusionMapping( + TimeCorrectionExclusion( + 'old-segment', + DateTime.utc(2030, 1, 4, 9).toIso8601String(), + 4, + ), + _slot(4), + ), + TimeCorrectionExclusionMapping( + TimeCorrectionExclusion( + 'old-segment', + DateTime.utc(2030, 1, 5, 9).toIso8601String(), + 5, + ), + null, + ), + ], + protectedSlots: many != null ? [] : [_slot(1)], + firstSlot: _slot(1), + workUnits: 0, + ); + +Widget _app(Widget child, {String locale = 'en', double scale = 1}) => + MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: SingleChildScrollView( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + ), + ); + +void main() { + for (final locale in ['ko', 'en']) { + testWidgets('$locale plan remains readable at 320px and 2.5 text scaling', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _app( + RecurringTimeCorrectionPlanSummary( + mapping: _mapping(), + unresolvedOverrides: const { + 'override': ScheduleTimeResolutionStatus.ambiguous, + }, + confirmedDetachedIds: const {}, + confirmedUnmatchedExclusions: const {}, + onDetachedChanged: (_) {}, + onUnmatchedExclusionsChanged: (_) {}, + onResolveOverride: (_) {}, + ), + locale: locale, + scale: 2.5, + ), + ); + await tester.pumpAndSettle(); + expect( + find.text( + locale == 'ko' ? '반복 일정 변경 내용' : 'Recurring schedule changes', + ), + findsOneWidget, + ); + expect( + find.textContaining( + locale == 'ko' + ? '과거 시간대 규칙을 재현' + : 'does not reconstruct historical time-zone rules', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const Key('plan-unmatched-exclusions')), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'mapped time override is not presented as the new slot appointment instant', + (tester) async { + await tester.pumpWidget( + _app( + RecurringTimeCorrectionPlanSummary( + mapping: _mapping(), + unresolvedOverrides: const {}, + confirmedDetachedIds: const {}, + confirmedUnmatchedExclusions: const {}, + onDetachedChanged: (_) {}, + onUnmatchedExclusionsChanged: (_) {}, + ), + ), + ); + final override = find.byKey(const ValueKey('mapped-override')); + final value = tester + .widget( + find.descendant(of: override, matching: find.byType(Text)).last, + ) + .data!; + expect(value, contains('Asia/Seoul')); + expect(value, contains('+09:00')); + expect( + value, + contains( + 'individually chosen appointment time and zone stay unchanged', + ), + ); + expect(value, isNot(contains('New appointment instant'))); + final moved = tester + .widget( + find + .descendant( + of: find.byKey(const ValueKey('mapped-mapped')), + matching: find.byType(Text), + ) + .last, + ) + .data!; + expect( + moved, + contains('New appointment instant: 2030-01-02T09:00:00.000Z'), + ); + expect(moved, contains('Original slot:')); + }, + ); + + testWidgets( + 'standalone and unmatched exclusion acknowledgement sets stay separate and exact', + (tester) async { + final mapping = _mapping(); + var detached = {'outside-this-plan'}; + var exclusions = {'old-review-only'}; + var detachedChanges = 0; + var exclusionChanges = 0; + await tester.pumpWidget( + _app( + StatefulBuilder( + builder: (context, setState) => RecurringTimeCorrectionPlanSummary( + mapping: mapping, + unresolvedOverrides: const {}, + confirmedDetachedIds: detached, + confirmedUnmatchedExclusions: exclusions, + onDetachedChanged: (value) { + detachedChanges++; + setState(() => detached = value); + }, + onUnmatchedExclusionsChanged: (value) { + exclusionChanges++; + setState(() => exclusions = value); + }, + ), + ), + ), + ); + Finder checkbox(Key key) => + find.descendant(of: find.byKey(key), matching: find.byType(Checkbox)); + final standalone = checkbox(const ValueKey('detached-detached')); + await tester.ensureVisible(standalone); + await tester.tap(standalone.hitTestable()); + await tester.pumpAndSettle(); + expect(detached, {'detached'}); + expect(detachedChanges, 1); + expect(exclusionChanges, 0); + expect(() => detached.add('illegal'), throwsUnsupportedError); + final key = RecurringTimeCorrectionPlanSummary.exclusionKey( + mapping.exclusions.last.original, + ); + final exclusion = checkbox(ValueKey('exclusion-$key')); + await tester.ensureVisible(exclusion); + await tester.tap(exclusion.hitTestable()); + await tester.pumpAndSettle(); + expect(exclusions, {key}); + expect(detached, {'detached'}); + expect(exclusionChanges, 1); + await tester.ensureVisible(standalone); + await tester.tap(standalone.hitTestable()); + await tester.pumpAndSettle(); + expect(detached, isEmpty); + expect(exclusions, {key}); + }, + ); + + testWidgets( + 'individual override review dispatches its stable schedule ID without acknowledging anything', + (tester) async { + final resolved = []; + var acknowledgements = 0; + await tester.pumpWidget( + _app( + RecurringTimeCorrectionPlanSummary( + mapping: _mapping(), + unresolvedOverrides: const { + 'override': ScheduleTimeResolutionStatus.changed, + }, + confirmedDetachedIds: const {}, + confirmedUnmatchedExclusions: const {}, + onDetachedChanged: (_) => acknowledgements++, + onUnmatchedExclusionsChanged: (_) => acknowledgements++, + onResolveOverride: resolved.add, + ), + ), + ); + final action = find.byKey(const ValueKey('resolve-override')); + expect( + find.widgetWithText(TextButton, 'Review this time'), + findsOneWidget, + ); + await tester.ensureVisible(action); + await tester.tap(action.hitTestable()); + await tester.pumpAndSettle(); + expect(resolved, ['override']); + expect(acknowledgements, 0); + expect( + find.textContaining('Conflict review is separate'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'disabled plan cannot change acknowledgements or launch another review', + (tester) async { + var changes = 0; + await tester.pumpWidget( + _app( + RecurringTimeCorrectionPlanSummary( + mapping: _mapping(), + unresolvedOverrides: const { + 'override': ScheduleTimeResolutionStatus.unknownZone, + }, + confirmedDetachedIds: const {}, + confirmedUnmatchedExclusions: const {}, + enabled: false, + onDetachedChanged: (_) => changes++, + onUnmatchedExclusionsChanged: (_) => changes++, + onResolveOverride: (_) => changes++, + ), + ), + ); + final action = find.byKey(const ValueKey('resolve-override')); + expect( + find.widgetWithText(TextButton, 'Review this time'), + findsOneWidget, + ); + await tester.ensureVisible(action); + await tester.tap(action.hitTestable()); + final checkbox = find.descendant( + of: find.byKey(const ValueKey('detached-detached')), + matching: find.byType(Checkbox), + ); + await tester.ensureVisible(checkbox); + await tester.tap(checkbox.hitTestable()); + await tester.pumpAndSettle(); + expect(changes, 0); + }, + ); + + testWidgets( + 'large plans render one page per section and reset the page for a new plan', + (tester) async { + final first = _mapping(many: 25); + Widget plan(RecurringTimeCorrectionMapping mapping) => _app( + RecurringTimeCorrectionPlanSummary( + mapping: mapping, + unresolvedOverrides: const {}, + confirmedDetachedIds: const {}, + confirmedUnmatchedExclusions: const {}, + onDetachedChanged: (_) {}, + onUnmatchedExclusionsChanged: (_) {}, + ), + ); + await tester.pumpWidget(plan(first)); + expect(find.byType(ListTile), findsNWidgets(10)); + expect(find.text('Appointment row-1'), findsOneWidget); + expect(find.text('Appointment row-11'), findsNothing); + final next = find.byKey(const Key('plan-page-next')); + await tester.ensureVisible(next); + await tester.tap(next.hitTestable()); + await tester.pumpAndSettle(); + expect(find.byType(ListTile), findsNWidgets(10)); + expect(find.text('Appointment row-1'), findsNothing); + expect(find.text('Appointment row-11'), findsOneWidget); + await tester.pumpWidget(plan(_mapping(many: 25))); + await tester.pumpAndSettle(); + expect(find.text('Appointment row-1'), findsOneWidget); + expect(find.text('Appointment row-11'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/presentation/recurring/recurring_time_correction_screen_test.dart b/test/presentation/recurring/recurring_time_correction_screen_test.dart new file mode 100644 index 00000000..b10e9f6e --- /dev/null +++ b/test/presentation/recurring/recurring_time_correction_screen_test.dart @@ -0,0 +1,613 @@ +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_time_correction.dart'; +import 'package:on_time_front/domain/repositories/recurring_time_correction_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_time_correction_repository.dart'; +import 'package:on_time_front/domain/use-cases/recurring_time_correction_workflow.dart'; +import 'package:on_time_front/domain/use-cases/schedule_time_correction_workflow.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurring_time_correction_screen.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_time_correction_screen.dart'; + +final _civil = DateTime.utc(2030, 1, 2, 10); +const _preparation = PreparationEntity(preparationStepList: []); +ScheduleEntity _row(String id) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Meeting $id', + scheduleTime: _civil, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 3600, + isChanged: false, + isStarted: false, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + recurringSegmentId: 'segment', + recurringSlotKey: _civil.toIso8601String(), + recurringOrdinal: 1, + preparationDefinitionId: 'definition', +); +ScheduleTimeCorrectionReview _anchor(String id, {int revision = 1}) { + final row = _row(id); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: _civil, + timeZoneId: 'UTC', + count: 3, + ); + return ScheduleTimeCorrectionReview( + snapshot: ScheduleEditSnapshot( + row, + _preparation, + ScheduleEditBaseline( + store: 'store', + generation: 0, + revision: revision, + incarnation: id, + version: revision, + rootId: 'segment', + rootIncarnation: 'root', + rootVersion: revision, + ), + segment: RecurringSegment( + id: 'segment', + seriesId: 'series', + rule: rule, + schedule: row, + preparation: _preparation, + preparationId: 'definition', + fromSlot: _civil, + createdAt: DateTime.utc(2029), + ), + ), + resolution: ScheduleTimeResolver.resolve(row, nowUtc: DateTime.utc(2029)), + ruleIdentity: 'test-rules', + validationNowUtc: DateTime.utc(2029), + ); +} + +RecurringTimeCorrectionReview _plan( + RecurringTimeCorrectionRequest request, { + bool acknowledgements = false, + bool unresolved = false, +}) { + final slot = RecurrenceSlot( + civilTime: _civil, + instantUtc: _civil, + offsetSeconds: 0, + ordinal: 1, + ); + return RecurringTimeCorrectionReview( + request: request, + sourceDigest: 'source-${request.anchorReview.snapshot.baseline.revision}', + mapping: RecurringTimeCorrectionMapping( + rule: request.rule, + automaticallyRetainedCount: true, + rows: [ + TimeCorrectionRowMapping(_row('mapped'), slot), + if (acknowledgements) TimeCorrectionRowMapping(_row('detached'), null), + ], + protectedRows: [], + exclusions: acknowledgements + ? [ + TimeCorrectionExclusionMapping( + TimeCorrectionExclusion('segment', _civil.toIso8601String(), 1), + null, + ), + ] + : [], + protectedSlots: [], + firstSlot: slot, + workUnits: 1, + ), + unresolvedOverrides: unresolved + ? {'mapped': ScheduleTimeResolutionStatus.unknownZone} + : {}, + conflictProof: TimeCorrectionConflictProof( + conflicts: [], + through: _civil, + workUnits: 1, + possibleOverlaps: acknowledgements + ? [ + TimeCorrectionPossibleOverlap( + id: 'uncertain', + name: 'Uncertain appointment', + reason: 'unknownZone', + originalCivil: _civil, + timeZoneId: 'Old/Zone', + ), + ] + : [], + ), + ); +} + +class _Individual implements ScheduleTimeCorrectionRepository { + final reads = []; + int revision = 1; + final commands = []; + @override + Future review(String id) async { + reads.add(id); + return _anchor(id, revision: revision); + } + + @override + Future reviewChoice( + ScheduleTimeCorrectionCommand command, + ) async => + TimeCorrectionConflictProof(conflicts: [], through: _civil, workUnits: 0); + @override + Future confirm( + ScheduleTimeCorrectionCommand command, + ) async { + commands.add(command); + revision++; + return ScheduleSaveReceipt( + scheduleId: command.review.snapshot.schedule.id, + mutationId: command.mutationId, + generation: 0, + changed: true, + ); + } + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => true; +} + +class _Repository implements RecurringTimeCorrectionRepository { + final requests = []; + final commands = []; + Future Function( + RecurringTimeCorrectionRequest, + )? + reader; + bool acknowledgements = false, unresolved = false; + Object? failure; + @override + Future review( + RecurringTimeCorrectionRequest request, + ) async { + requests.add(request); + return reader != null + ? reader!(request) + : _plan( + request, + acknowledgements: acknowledgements, + unresolved: unresolved, + ); + } + + @override + Future confirm( + RecurringTimeCorrectionCommand command, + ) async { + commands.add(command); + if (failure != null) throw failure!; + return ScheduleSaveReceipt( + scheduleId: command.review.request.anchorReview.snapshot.schedule.id, + mutationId: command.mutationId, + generation: 0, + changed: true, + ); + } + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => true; +} + +class _Effects extends Fake implements ScheduleMutationAlarmEffectsCoordinator { + int calls = 0; + bool complete = true; + @override + Future afterCommit() async { + calls++; + return complete; + } +} + +class _FormBloc extends Fake implements ScheduleFormBloc { + _FormBloc(this.state); + @override + final ScheduleFormState state; + final events = []; + @override + Stream get stream => const Stream.empty(); + @override + void add(ScheduleFormEvent event) => events.add(event); + @override + Future close() async {} +} + +void main() { + late _Repository repository; + late _Individual individual; + late _Effects effects; + late RecurringTimeCorrectionWorkflow workflow; + late ScheduleTimeCorrectionWorkflow individualWorkflow; + var saved = 0; + setUp(() { + repository = _Repository(); + individual = _Individual(); + effects = _Effects(); + workflow = RecurringTimeCorrectionWorkflow(repository, effects); + individualWorkflow = ScheduleTimeCorrectionWorkflow(individual, effects); + saved = 0; + }); + Future pump( + WidgetTester tester, { + String id = 'one', + bool settle = true, + }) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: RecurringTimeCorrectionScreen( + scheduleId: id, + workflow: workflow, + individualWorkflow: individualWorkflow, + onSaved: () => saved++, + ), + ), + ), + ); + if (settle) await tester.pumpAndSettle(); + } + + Future tapKey( + WidgetTester tester, + String key, { + bool settle = true, + bool? checkboxBefore, + }) async { + final finder = find.byKey(ValueKey(key)); + if (finder.evaluate().isEmpty) { + // ListView lazily removes far-away controls. Reset only fixture scroll + // position, then use real visible taps for every product action. + tester + .state(find.byType(Scrollable).first) + .position + .jumpTo(0); + await tester.pump(); + await tester.scrollUntilVisible(finder, 250, maxScrolls: 60); + } + await tester.ensureVisible(finder); + await tester.pump(); + expect(finder.hitTestable(), findsOneWidget); + if (checkboxBefore != null) { + expect(tester.widget(finder).value, checkboxBefore); + } + await tester.tap(finder.hitTestable()); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } + } + + Future buildPlan(WidgetTester tester) => + tapKey(tester, 'build-recurring-time-plan'); + Future openConfirm(WidgetTester tester) async { + await tapKey(tester, 'review-recurring-time-confirm'); + expect( + find.byKey(const Key('confirm-recurring-time')).hitTestable(), + findsOneWidget, + ); + } + + Future confirm(WidgetTester tester) async { + await openConfirm(tester); + await tester.tap( + find.byKey(const Key('confirm-recurring-time')).hitTestable(), + ); + await tester.pumpAndSettle(); + } + + Future acknowledge(WidgetTester tester) async { + await tapKey(tester, 'detached-detached'); + await tapKey(tester, 'exclusion-segment\n${_civil.toIso8601String()}'); + await tapKey(tester, 'ack-recurring-uncertain'); + } + + testWidgets( + 'plan review and cancelling the final dialog never save or reconcile', + (tester) async { + await pump(tester); + await buildPlan(tester); + expect(repository.requests, hasLength(1)); + expect(repository.commands, isEmpty); + expect(effects.calls, 0); + await openConfirm(tester); + await tester.tap(find.text('Cancel').hitTestable()); + await tester.pumpAndSettle(); + expect(repository.commands, isEmpty); + expect(effects.calls, 0); + expect(saved, 0); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets('a newly built plan requires fresh exact acknowledgements', ( + tester, + ) async { + repository.acknowledgements = true; + await pump(tester); + await buildPlan(tester); + await tapKey(tester, 'review-recurring-time-confirm'); + expect(find.byKey(const Key('confirm-recurring-time')), findsNothing); + await acknowledge(tester); + await buildPlan(tester); + expect(repository.requests, hasLength(2)); + for (final key in [ + 'detached-detached', + 'exclusion-segment\n${_civil.toIso8601String()}', + 'ack-recurring-uncertain', + ]) { + await tapKey(tester, key, checkboxBefore: false); + } + // Each set was independently reset. Withhold one again: the other two + // acknowledgements cannot substitute for the missing uncertain-impact set. + await tapKey(tester, 'ack-recurring-uncertain', checkboxBefore: true); + await tapKey(tester, 'review-recurring-time-confirm'); + expect(find.byKey(const Key('confirm-recurring-time')), findsNothing); + expect(repository.commands, isEmpty); + expect(effects.calls, 0); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets( + 'a late plan from the old schedule cannot become the new owner plan', + (tester) async { + final pending = Completer(); + repository.reader = (request) => + request.anchorReview.snapshot.schedule.id == 'one' + ? pending.future + : Future.value(_plan(request)); + await pump(tester); + await tapKey(tester, 'build-recurring-time-plan', settle: false); + final old = repository.requests.single; + await pump(tester, id: 'two'); + pending.complete(_plan(old)); + await tester.pumpAndSettle(); + expect(find.text('Meeting two'), findsOneWidget); + expect( + find.byKey(const Key('review-recurring-time-confirm')), + findsNothing, + ); + await buildPlan(tester); + await confirm(tester); + expect( + repository + .commands + .single + .review + .request + .anchorReview + .snapshot + .schedule + .id, + 'two', + ); + expect(saved, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets('accepting an old owner dialog cannot save either owner', ( + tester, + ) async { + await pump(tester); + await buildPlan(tester); + await openConfirm(tester); + await pump(tester, id: 'two'); + await tester.tap( + find.byKey(const Key('confirm-recurring-time')).hitTestable(), + ); + await tester.pumpAndSettle(); + expect(repository.commands, isEmpty); + expect(effects.calls, 0); + expect(saved, 0); + expect(find.text('Meeting two'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets( + 'uncertain save response retries the identical confirmed intent without rebuilding the plan', + (tester) async { + repository.failure = StateError('response unavailable'); + await pump(tester); + await buildPlan(tester); + await confirm(tester); + expect(repository.commands, hasLength(1)); + expect(saved, 0); + expect(effects.calls, 0); + final original = repository.commands.single; + repository.failure = null; + await confirm(tester); + expect(repository.requests, hasLength(1)); + expect(repository.commands, hasLength(2)); + expect(identical(repository.commands.last, original), isTrue); + expect(repository.commands.last.mutationId, original.mutationId); + expect(saved, 1); + expect(effects.calls, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'pending notification synchronization retries effects without another save', + (tester) async { + effects.complete = false; + await pump(tester); + await buildPlan(tester); + await confirm(tester); + expect(repository.commands, hasLength(1)); + expect(saved, 0); + expect(effects.calls, 1); + final retry = find.text('Retry notification synchronization'); + await tester.scrollUntilVisible(retry, 250); + expect(retry.hitTestable(), findsOneWidget); + effects.complete = true; + await tester.tap(retry.hitTestable()); + await tester.pumpAndSettle(); + expect(repository.commands, hasLength(1)); + expect(effects.calls, 2); + expect(saved, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'a changed authority requires a fresh record instead of retrying a rejected command', + (tester) async { + repository.failure = const ScheduleSaveRejected( + ScheduleSaveFailure.conflict, + ); + await pump(tester); + await buildPlan(tester); + await confirm(tester); + await tapKey(tester, 'review-recurring-time-confirm'); + expect(find.byKey(const Key('confirm-recurring-time')), findsNothing); + expect(repository.commands, hasLength(1)); + individual.revision = 2; + await tapKey(tester, 'reload-recurring-time'); + repository.failure = null; + await buildPlan(tester); + await confirm(tester); + expect( + repository + .commands + .last + .review + .request + .anchorReview + .snapshot + .baseline + .revision, + 2, + ); + expect( + repository.commands.last.mutationId, + isNot(repository.commands.first.mutationId), + ); + expect(saved, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'unresolved individual review blocks the plan and returning requires a fresh anchor', + (tester) async { + repository.unresolved = true; + await pump(tester); + await buildPlan(tester); + await tapKey(tester, 'review-recurring-time-confirm'); + expect(find.byKey(const Key('confirm-recurring-time')), findsNothing); + await tapKey(tester, 'resolve-mapped'); + expect(find.byType(ScheduleTimeCorrectionScreen), findsOneWidget); + expect(individual.reads, ['one', 'mapped']); + individual.revision = 2; + // Back is cancellation; even then the old following review loses authority. + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(individual.reads, ['one', 'mapped', 'one']); + expect( + find.byKey(const Key('review-recurring-time-confirm')), + findsNothing, + ); + repository.unresolved = false; + await buildPlan(tester); + await confirm(tester); + expect( + repository + .commands + .single + .review + .request + .anchorReview + .snapshot + .baseline + .revision, + 2, + ); + expect(individual.commands, isEmpty); + expect(saved, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + for (final scope in RecurringEditScope.values) { + testWidgets( + 'actual edit entry routes unresolved recurring $scope to the correct review', + (tester) async { + getIt.pushNewScope(); + addTearDown(getIt.popScope); + final form = _FormBloc( + ScheduleFormState( + status: ScheduleFormStatus.success, + id: 'one', + originalSchedule: _anchor('one').snapshot.schedule, + recurringScope: scope, + mutationId: 'route-intent', + ), + ); + getIt.registerFactory(() => form); + getIt.registerSingleton(workflow); + getIt.registerSingleton( + individualWorkflow, + ); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ScheduleEditScreen(scheduleId: 'one', scope: scope), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(RecurringTimeCorrectionScreen), + scope == RecurringEditScope.following ? findsOneWidget : findsNothing, + ); + expect( + find.byType(ScheduleTimeCorrectionScreen), + scope == RecurringEditScope.occurrence + ? findsOneWidget + : findsNothing, + ); + final request = form.events + .whereType() + .single; + expect(request.scheduleId, 'one'); + expect(request.scope, scope); + expect(individual.reads, ['one']); + expect(repository.commands, isEmpty); + expect(individual.commands, isEmpty); + expect(effects.calls, 0); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } +} diff --git a/test/presentation/recurring/refreshed_screens_test.dart b/test/presentation/recurring/refreshed_screens_test.dart index d20d1c6f..dee95f9e 100644 --- a/test/presentation/recurring/refreshed_screens_test.dart +++ b/test/presentation/recurring/refreshed_screens_test.dart @@ -1,3 +1,6 @@ +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/entities/schedule_deletion.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; import 'package:on_time_front/core/database/local_reset_protocol.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -88,8 +91,21 @@ void main() { await _pump(tester, RecurrenceReviewSheet(review: review, form: form)); await captureRefresh(tester, 'conflicts'); for (var i = 0; i < 3; i++) { - await tester.tap(find.byType(CheckboxListTile).at(i)); - await tester.pump(); + final row = find.byKey( + ValueKey('review-occurrence-${review.slots[i].key}'), + ); + await tester.scrollUntilVisible( + row, + 160, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + final control = find.descendant(of: row, matching: find.byType(Checkbox)); + await Scrollable.ensureVisible(tester.element(control), alignment: .5); + await tester.pumpAndSettle(); + expect(control.hitTestable(), findsOneWidget); + await tester.tap(control); + await tester.pumpAndSettle(); } expect( tester @@ -305,7 +321,8 @@ void main() { await tester.tap(find.text('반복 종료')); await tester.pumpAndSettle(); await captureRefresh(tester, 'end-confirm'); - expect(find.text('개별로 수정한 이후 회차도 삭제 대상에 포함됩니다.'), findsOneWidget); + expect(find.textContaining('이후'), findsWidgets); + expect(find.textContaining('이전'), findsWidgets); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -378,7 +395,33 @@ class _Management implements RecurringSchedulesUseCase { _Management(this.summaries); final List summaries; @override + DeleteScheduleUseCase get deletions => + _ManagementDeletion(summaries.first.next!); + @override Future> list() async => summaries; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _ManagementDeletion extends Fake implements DeleteScheduleUseCase { + _ManagementDeletion(this.schedule); + final ScheduleEntity schedule; + @override + int get currentGeneration => 0; + @override + bool isCurrentGeneration(int generation) => generation == 0; + @override + Future prepare( + String id, { + RecurringEditScope scope = RecurringEditScope.occurrence, + }) async => ScheduleDeletionIntent( + intentId: 'ui', + scope: scope, + targets: [], + snapshot: ScheduleEditSnapshot( + schedule, + _prep, + const ScheduleEditBaseline(store: 'ui', generation: 0, revision: 0), + ), + ); +} diff --git a/test/presentation/recurring/u02_recurrence_acceptance_integration_test.dart b/test/presentation/recurring/u02_recurrence_acceptance_integration_test.dart new file mode 100644 index 00000000..4874d7de --- /dev/null +++ b/test/presentation/recurring/u02_recurrence_acceptance_integration_test.dart @@ -0,0 +1,539 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_review_sheet.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/components/schedule_multi_page_form.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; +import '../../helpers/u02_save_fixture.dart'; + +class _LoadAdjacent extends Fake + implements LoadAdjacentScheduleWithPreparationUseCase { + @override + Future call({ + required DateTime startDate, + required DateTime endDate, + }) async {} +} + +class _Adjacent extends Fake + implements GetAdjacentSchedulesWithPreparationUseCase { + @override + Future call({ + required DateTime selectedDateTime, + String? currentScheduleId, + required DateTime startDate, + required DateTime endDate, + }) async => const AdjacentSchedulesWithPreparationEntity(); +} + +Future _mount( + WidgetTester tester, + U02SaveFixture f, { + required bool editing, +}) async { + getIt.registerFactoryParam( + (form, _) => ScheduleDateTimeCubit(form, _LoadAdjacent(), _Adjacent()), + ); + addTearDown(getIt.reset); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + body: BlocProvider.value( + value: f.bloc, + child: ScheduleMultiPageForm( + onSaved: () => f.bloc.add( + editing + ? const ScheduleFormUpdated() + : const ScheduleFormCreated(), + ), + ), + ), + ), + ), + ), + child: const Text('Open actual form'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open actual form')); + await tester.pumpAndSettle(); +} + +Future _review( + WidgetTester tester, + U02SaveFixture f, { + required bool editing, +}) async { + await tester.runAsync( + () => f.send( + editing ? const ScheduleFormUpdated() : const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(RecurrenceReviewSheet), findsOneWidget); +} + +Finder _action(String text) => find.descendant( + of: find.byType(RecurrenceReviewSheet), + matching: find.text(text), +); +Future _confirm(WidgetTester tester, U02SaveFixture f) async { + final saved = f.bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + expect(_action('Save').hitTestable(), findsOneWidget); + await tester.tap(_action('Save')); + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync(() => saved.timeout(const Duration(seconds: 5))); + await tester.pumpAndSettle(); +} + +void main() { + test( + 'U02-17 actual finite-rule validation rejection requires a new review before retry', + () async { + final f = U02SaveFixture(); + await f.open(); + addTearDown(f.close); + await f.draft(-14400); + await f.send( + ScheduleFormRecurringChanged( + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: f.bloc.state.scheduleTime!, + timeZoneId: 'America/New_York', + until: DateTime.utc(2026, 11, 2), + repeatedTime: RepeatedCivilTime.first, + ), + ), + (s) => s.recurrenceRule != null, + ); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ); + final stale = f.bloc.state.recurrenceReview!; + final before = await f.revision(); + f.now = DateTime.utc(2026, 12, 1); + await f.send( + ScheduleFormRecurrenceReviewConfirmed(stale), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + expect(f.bloc.state.submissionError, '이 조건으로 만들 수 있는 일정이 없어요.'); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(await f.revision(), before); + expect(f.effects.calls, 0); + f.now = DateTime.utc(2026, 10, 1); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ); + final fresh = f.bloc.state.recurrenceReview!; + expect(identical(fresh, stale), isFalse); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + await f.send( + ScheduleFormRecurrenceReviewConfirmed(fresh), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + expect(await f.db.select(f.db.schedules).get(), hasLength(2)); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 1); + }, + ); + + test( + 'U02-17 recurring committed response loss replays the same accepted projection without duplicate rows or revision', + () async { + final f = U02SaveFixture(loseFirstSaveResponseOnce: true); + await f.open(); + addTearDown(f.close); + await f.draft(-14400); + final civil = f.bloc.state.scheduleTime!; + await f.send( + ScheduleFormRecurringChanged( + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: civil, + timeZoneId: 'America/New_York', + count: 2, + repeatedTime: RepeatedCivilTime.first, + ), + ), + (s) => s.recurrenceRule != null, + ); + final before = await f.revision(); + final mutation = f.bloc.state.mutationId; + final statuses = []; + final observation = f.bloc.stream + .map((s) => s.submissionStatus) + .distinct() + .listen(statuses.add); + addTearDown(observation.cancel); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ); + final review = f.bloc.state.recurrenceReview!; + final selected = {}; + final approved = ScheduleFormRecurrenceReviewConfirmed( + review, + excludedSlots: selected, + ); + // Mutating the caller's set after event construction cannot change approval. + selected.addAll(review.slots.map((slot) => slot.key)); + await f.send( + approved, + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + final committed = await f.db.select(f.db.schedules).get(); + expect(committed, hasLength(2)); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 0); + expect(f.bloc.state.mutationId, mutation); + expect(f.bloc.state.saveReceipt, isNull); + // Durable receipt replay precedes current-time validation in the writer. + f.now = DateTime.utc(2026, 12, 1); + await f.send( + ScheduleFormRecurrenceReviewConfirmed(review), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + expect(await f.db.select(f.db.schedules).get(), committed); + expect(await f.revision(), before + 1); + expect(f.bloc.state.saveReceipt!.mutationId, mutation); + expect(f.effects.calls, 1); + expect( + statuses.where((s) => s == ScheduleFormSubmissionStatus.review), + hasLength(1), + ); + }, + ); + + test( + 'U02-17 direct Save cannot bypass review and stale typed confirmations cannot approve a new review or revision', + () async { + final f = U02SaveFixture(); + await f.open(); + addTearDown(f.close); + await f.draft(-14400); + final civil = f.bloc.state.scheduleTime!; + await f.send( + ScheduleFormRecurringChanged( + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: civil, + timeZoneId: 'America/New_York', + count: 2, + repeatedTime: RepeatedCivilTime.first, + ), + ), + (s) => s.recurrenceRule != null, + ); + final before = await f.revision(); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ); + final first = f.bloc.state.recurrenceReview!; + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ); + final second = f.bloc.state.recurrenceReview!; + expect(identical(first, second), isFalse); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + f.bloc.add(ScheduleFormRecurrenceReviewConfirmed(first)); + await Future.delayed(Duration.zero); + await f.db.customSelect('SELECT 1').get(); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(f.bloc.ownsRecurrenceReview(second), isTrue); + await f.send( + const ScheduleFormScheduleNameChanged( + scheduleName: 'Changed after review', + ), + (s) => s.scheduleName == 'Changed after review', + ); + f.bloc.add(ScheduleFormRecurrenceReviewConfirmed(second)); + await Future.delayed(Duration.zero); + await f.db.customSelect('SELECT 1').get(); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(await f.revision(), before); + expect(f.effects.calls, 0); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, + ); + final fresh = f.bloc.state.recurrenceReview!; + await f.send( + ScheduleFormRecurrenceReviewConfirmed(fresh), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + final rows = await f.db.select(f.db.schedules).get(); + expect(rows, hasLength(2)); + expect( + rows.map((row) => row.scheduleName), + everyElement('Changed after review'), + ); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 1); + }, + ); + + testWidgets( + 'U02-16 mixed-zone following draft changes zone through actual review and SQLite commit', + (tester) async { + final f = U02SaveFixture(); + await tester.runAsync(f.open); + await tester.runAsync(() => f.draft(-14400)); + addTearDown(f.close); + final start = DateTime.utc(2026, 10, 31, 1, 30, 1, 123, 456); + final base = f.bloc.state + .createEntity(f.bloc.state) + .copyWith(scheduleTime: start); + final prep = f.bloc.state.preparation!; + await tester.runAsync( + () => f.recurring.create( + base, + prep, + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'America/New_York', + count: 3, + repeatedTime: RepeatedCivilTime.second, + ), + ), + ); + final initial = + (await tester.runAsync( + () => f.db.scheduleDao.getScheduleList(), + ))!.map((row) => row.toScheduleEntity()).toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + final original = initial[1]; + await tester.runAsync( + () => f.recurring.updateOccurrence( + original, + original.copyWith( + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + ), + prep, + preparationChanged: false, + ), + ); + // The prefix is now historical and must not move with following edits. + f.now = DateTime.utc(2026, 10, 31, 13); + final prefix = await tester.runAsync( + () => f.db.scheduleDao.getScheduleById(initial.first.id), + ); + await tester.runAsync( + () => f.send( + ScheduleFormEditRequested( + scheduleId: original.id, + scope: RecurringEditScope.following, + ), + (s) => + s.status == ScheduleFormStatus.success && + s.originalSchedule != null, + ), + ); + expect(f.bloc.state.timeZoneId, 'America/New_York'); + expect(f.bloc.state.originalSchedule!.timeZoneId, 'Asia/Seoul'); + final civil = f.bloc.state.scheduleTime!; + await tester.runAsync( + () => f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'UTC', + timeZoneExplicitlySelected: true, + occurrenceOffsetSeconds: 0, + ), + (s) => s.timeZoneId == 'UTC', + ), + ); + final beforeRevision = await tester.runAsync(f.revision); + await _mount(tester, f, editing: true); + await _review(tester, f, editing: true); + final review = f.bloc.state.recurrenceReview!; + expect(review.totalOccurrences, 2); + expect(review.slots, hasLength(2)); + final first = review.occurrences[review.slots.first.key]!; + expect(first.schedule.timeZoneId, 'Asia/Seoul'); + expect(first.schedule.scheduleTime, civil); + expect( + first.preparationStartUtc, + DateTime.utc(2026, 10, 31, 16, 20, 1, 123, 456), + ); + expect( + find.text('Changes: this and following occurrences'), + findsOneWidget, + ); + final preview = find.byType(ScheduleZonedTime).first; + await tester.ensureVisible(preview); + await tester.pumpAndSettle(); + expect(find.textContaining('Asia/Seoul'), findsWidgets); + expect(await tester.runAsync(f.revision), beforeRevision); + await _confirm(tester, f); + expect( + await tester.runAsync( + () => f.db.scheduleDao.getScheduleById(initial.first.id), + ), + prefix, + ); + final rows = + (await tester.runAsync( + () => f.db.scheduleDao.getScheduleList(), + ))!.map((row) => row.toScheduleEntity()).toList() + ..sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + expect(rows, hasLength(3)); + expect(rows[1].timeZoneId, 'Asia/Seoul'); + expect(rows[1].occurrenceOffsetSeconds, 32400); + expect(rows[2].timeZoneId, 'UTC'); + expect(rows[2].occurrenceOffsetSeconds, 0); + expect( + rows[2].scheduleTime, + DateTime.utc(2026, 11, 2, 1, 30, 1, 123, 456), + ); + expect(await tester.runAsync(f.revision), beforeRevision! + 1); + expect(f.effects.calls, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'U02-17 same-owner recurring input change rejects an open old review and requires a fresh review', + (tester) async { + final f = U02SaveFixture(); + await tester.runAsync(f.open); + await tester.runAsync(() => f.draft(-14400)); + addTearDown(f.close); + final civil = DateTime.utc(2026, 11, 1, 9); + await tester.runAsync( + () => f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + ), + (s) => s.timeZoneId == 'UTC', + ), + ); + await tester.runAsync( + () => f.send( + ScheduleFormRecurringChanged( + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: civil, + timeZoneId: 'UTC', + count: 2, + ), + ), + (s) => s.recurrenceRule != null, + ), + ); + await _mount(tester, f, editing: false); + final owner = f.bloc.formOwner; + final beforeRevision = await tester.runAsync(f.revision); + await _review(tester, f, editing: false); + final idle = f.bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.idle, + ); + expect(_action('Back').hitTestable(), findsOneWidget); + await tester.tap(_action('Back')); + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync(() => idle.timeout(const Duration(seconds: 5))); + await tester.pumpAndSettle(); + expect(f.bloc.state.scheduleTime, civil); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + isEmpty, + ); + await tester.runAsync( + () => f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + ), + (s) => s.timeZoneId == 'Asia/Seoul', + ), + ); + await _review(tester, f, editing: false); + final oldReview = f.bloc.state.recurrenceReview; + // Same owner, while the real Seoul review is open. The old modal must + // never approve Tokyo merely because both zones currently have +09:00. + await tester.runAsync( + () => f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'Asia/Tokyo', + occurrenceOffsetSeconds: 32400, + ), + (s) => s.timeZoneId == 'Asia/Tokyo', + ), + ); + expect(identical(f.bloc.formOwner, owner), isTrue); + expect(_action('Save').hitTestable(), findsOneWidget); + await tester.tap(_action('Save')); + // Give the actual listener and SQLite queue bounded opportunities; an + // ignored stale answer may intentionally emit no terminal state. + for (var attempt = 0; attempt < 10; attempt++) { + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync(() => f.db.customSelect('SELECT 1').get()); + } + await tester.pumpAndSettle(); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + isEmpty, + reason: + 'An old real modal cannot authorize a different same-owner draft.', + ); + expect(await tester.runAsync(f.revision), beforeRevision); + expect(f.effects.calls, 0); + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + expect(f.bloc.state.timeZoneId, 'Asia/Tokyo'); + await _review(tester, f, editing: false); + expect(identical(f.bloc.state.recurrenceReview, oldReview), isFalse); + expect( + f.bloc.state.recurrenceReview!.occurrences.values.map( + (o) => o.schedule.timeZoneId, + ), + everyElement('Asia/Tokyo'), + ); + await _confirm(tester, f); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + hasLength(2), + ); + expect(await tester.runAsync(f.revision), beforeRevision! + 1); + expect(f.effects.calls, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/schedule_create/bloc/following_zone_draft_integration_test.dart b/test/presentation/schedule_create/bloc/following_zone_draft_integration_test.dart new file mode 100644 index 00000000..d2d64f17 --- /dev/null +++ b/test/presentation/schedule_create/bloc/following_zone_draft_integration_test.dart @@ -0,0 +1,173 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/domain/use-cases/schedule_save_workflow.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; + +class _UnusedLoad extends Fake implements LoadPreparationByScheduleIdUseCase {} + +class _UnusedPreparation extends Fake + implements GetPreparationByScheduleIdUseCase {} + +class _UnusedDefault extends Fake implements GetDefaultPreparationUseCase {} + +class _UnusedSchedule extends Fake implements GetScheduleByIdUseCase {} + +class _Effects extends Fake + implements ScheduleMutationAlarmEffectsCoordinator {} + +void main() { + for (final scenario in [ + (choice: RepeatedCivilTime.first, unavailable: false), + (choice: RepeatedCivilTime.second, unavailable: false), + (choice: RepeatedCivilTime.first, unavailable: true), + ]) { + final choice = scenario.choice; + test( + 'actual override in Seoul loads following rule in New York with ${choice.name} fold and exact civil (unavailable: ${scenario.unavailable})', + () async { + final now = DateTime.utc(2026, 10, 1); + final start = DateTime.utc(2026, 10, 31, 1, 30, 1, 123, 456); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ); + final recurring = RecurringScheduleRepositoryImpl(db, now: () => now); + final aggregate = ScheduleAggregateRepositoryImpl( + db, + recurring, + gate: LocalDataOperationGate(), + now: () => now, + ); + const prep = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 10), + ), + ], + ); + final base = ScheduleEntity( + id: 'series', + place: const PlaceEntity(id: 'p', placeName: 'Place'), + scheduleName: 'Base schedule', + scheduleTime: start, + timeZoneId: 'America/New_York', + occurrenceOffsetSeconds: -14400, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: start, + timeZoneId: 'America/New_York', + count: 3, + repeatedTime: choice, + ); + await recurring.create(base, prep, rule); + final rows = (await db.scheduleDao.getScheduleList()) + .map((row) => row.toScheduleEntity()) + .toList(); + final original = rows.singleWhere((row) => row.scheduleTime.day == 1); + final override = original.copyWith( + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + ); + await recurring.updateOccurrence( + original, + override, + prep, + preparationChanged: false, + ); + final beforeRows = await db.select(db.schedules).get(); + final beforeRevision = + (await db.select(db.users).getSingle()).dataRevision; + final load = LoadScheduleFormDraftUseCase.withOverrides( + _UnusedLoad(), + _UnusedPreparation(), + _UnusedDefault(), + _UnusedSchedule(), + now: () => now, + newId: () => 'unused', + aggregate: aggregate, + ); + if (scenario.unavailable) { + final location = tz.timeZoneDatabase.locations.remove( + 'America/New_York', + )!; + addTearDown(() { + tz.timeZoneDatabase.locations['America/New_York'] = location; + }); + } + final workflow = ScheduleSaveWorkflow(aggregate, _Effects()); + final bloc = ScheduleFormBloc( + load, + CreateScheduleFormSubmissionUseCase(workflow), + UpdateScheduleFormSubmissionUseCase(workflow), + aggregate: aggregate, + now: () => now, + ); + addTearDown(bloc.close); + final loaded = bloc.stream + .firstWhere((s) => s.status == ScheduleFormStatus.success) + .timeout(const Duration(seconds: 10)); + bloc.add( + ScheduleFormEditRequested( + scheduleId: original.id, + scope: RecurringEditScope.following, + ), + ); + await loaded; + expect(bloc.state.timeZoneId, 'America/New_York'); + expect( + bloc.state.scheduleTime, + DateTime.utc(2026, 11, 1, 1, 30, 1, 123, 456), + ); + expect( + bloc.state.occurrenceOffsetSeconds, + scenario.unavailable + ? null + : choice == RepeatedCivilTime.first + ? -14400 + : -18000, + ); + expect(bloc.state.recurrenceRule!.repeatedTime, choice); + expect(bloc.state.originalSchedule!.timeZoneId, 'Asia/Seoul'); + expect( + (await db.select(db.users).getSingle()).dataRevision, + beforeRevision, + ); + expect(await db.select(db.schedules).get(), beforeRows); + }, + ); + } +} diff --git a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart index 5262c5cb..116f622b 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart @@ -105,7 +105,9 @@ void main() { id: 'schedule-1', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2027, 3, 20, 9, 0), + scheduleTime: DateTime.utc(2027, 3, 20, 9, 0), + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 9 * 60 * 60, moveTime: const Duration(minutes: 30), isChanged: false, isStarted: false, @@ -115,12 +117,16 @@ void main() { ScheduleFormDraft draftFromSchedule({ bool preparationChanged = false, + bool editing = false, Duration? spareTime = const Duration(minutes: 10), Object? scheduleTime = _unset, SchedulePreparationMode? originalPreparationMode, }) { return ScheduleFormDraft( id: schedule.id, + timeZoneId: schedule.timeZoneId, + occurrenceOffsetSeconds: schedule.occurrenceOffsetSeconds, + originalSchedule: editing ? schedule : null, placeId: schedule.place.id, placeName: schedule.place.placeName, scheduleName: schedule.scheduleName, @@ -136,11 +142,12 @@ void main() { ); } - ScheduleFormBloc buildBloc() { + ScheduleFormBloc buildBloc({DateTime Function()? now}) { return ScheduleFormBloc( loadScheduleFormDraftUseCase, createScheduleFormSubmissionUseCase, updateScheduleFormSubmissionUseCase, + now: now ?? () => DateTime.utc(2026, 9, 25), ); } @@ -173,7 +180,7 @@ void main() { scheduleTime: initialDate, ); }, - editHandler: (_) async => draftFromSchedule(), + editHandler: (_) async => draftFromSchedule(editing: true), ); createScheduleFormSubmissionUseCase = SpyCreateScheduleFormSubmissionUseCase(); @@ -181,6 +188,131 @@ void main() { SpyUpdateScheduleFormSubmissionUseCase(); }); + Future requestCreateReview(ScheduleFormBloc bloc) async { + final ready = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + bloc.add(const ScheduleFormCreated()); + return ready; + } + + Future readyCreate(ScheduleFormBloc bloc) async { + loadScheduleFormDraftUseCase.createHandler = + ({initialDate, currentUserSpareTime}) async => draftFromSchedule(); + await primeCreateState(bloc); + } + + test( + 'cancelled and superseded review never writes and preserves draft input', + () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + await readyCreate(bloc); + final first = (await requestCreateReview(bloc)).timeReview!; + final idle = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.idle, + ); + bloc.add(const ScheduleFormReviewDismissed()); + await idle; + expect(bloc.state.scheduleName, 'Meeting'); + expect(createScheduleFormSubmissionUseCase.submissions, isEmpty); + final second = (await requestCreateReview(bloc)).timeReview!; + expect(identical(first, second), isFalse); + final edited = bloc.stream.firstWhere( + (s) => s.scheduleName == 'Latest input', + ); + bloc.add( + const ScheduleFormScheduleNameChanged(scheduleName: 'Latest input'), + ); + await edited; + bloc.add(ScheduleFormTimeReviewConfirmed(second)); + await Future.delayed(Duration.zero); + expect(createScheduleFormSubmissionUseCase.submissions, isEmpty); + expect(bloc.state.scheduleName, 'Latest input'); + }, + ); + + test( + 'known transaction rejection requests a fresh review before retry', + () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + await readyCreate(bloc); + createScheduleFormSubmissionUseCase.handler = (_) async => + throw const ScheduleSaveRejected(ScheduleSaveFailure.conflict); + final first = (await requestCreateReview(bloc)).timeReview!; + final failed = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + bloc.add(ScheduleFormTimeReviewConfirmed(first)); + await failed; + createScheduleFormSubmissionUseCase.handler = null; + final next = (await requestCreateReview(bloc)).timeReview!; + expect(identical(first, next), isFalse); + expect(createScheduleFormSubmissionUseCase.submissions, hasLength(1)); + final saved = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + bloc.add(ScheduleFormTimeReviewConfirmed(next)); + await saved; + expect(createScheduleFormSubmissionUseCase.submissions, hasLength(2)); + }, + ); + + test( + 'unclassified response loss retains the same accepted mutation for replay', + () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + await readyCreate(bloc); + createScheduleFormSubmissionUseCase.handler = (_) async => + throw StateError('response lost'); + final review = (await requestCreateReview(bloc)).timeReview!; + final failed = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + bloc.add(ScheduleFormTimeReviewConfirmed(review)); + await failed; + final first = createScheduleFormSubmissionUseCase.submissions.single; + createScheduleFormSubmissionUseCase.handler = null; + final states = []; + final subscription = bloc.stream.listen( + (s) => states.add(s.submissionStatus), + ); + addTearDown(subscription.cancel); + final saved = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + bloc.add(const ScheduleFormCreated()); + await saved; + expect(states, isNot(contains(ScheduleFormSubmissionStatus.timeReview))); + expect( + createScheduleFormSubmissionUseCase.submissions.last.mutationId, + first.mutationId, + ); + expect(createScheduleFormSubmissionUseCase.submissions.last, first); + }, + ); + + test( + 'rewound clock invalidates uncommitted review without entering writer', + () async { + var now = DateTime.utc(2026, 9, 25); + final bloc = buildBloc(now: () => now); + addTearDown(bloc.close); + await readyCreate(bloc); + final review = (await requestCreateReview(bloc)).timeReview!; + now = now.subtract(const Duration(minutes: 1)); + final failed = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + bloc.add(ScheduleFormTimeReviewConfirmed(review)); + await failed; + expect(bloc.state.saveFailure, ScheduleSaveFailure.conflict); + expect(createScheduleFormSubmissionUseCase.submissions, isEmpty); + }, + ); + test('ScheduleFormCreateRequested maps loaded draft into state', () async { DateTime? requestedInitialDate; Duration? requestedSpareTime; @@ -281,7 +413,13 @@ void main() { (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, ); + final reviewReady = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); bloc.add(const ScheduleFormUpdated()); + final review = (await reviewReady).timeReview!; + expect(updateScheduleFormSubmissionUseCase.submissions, isEmpty); + bloc.add(ScheduleFormTimeReviewConfirmed(review)); await submitDone; final submission = updateScheduleFormSubmissionUseCase.submissions.single; @@ -289,7 +427,10 @@ void main() { expect(submission.schedule.place.id, 'place-1'); expect(submission.schedule.place.placeName, 'New Office'); expect(submission.schedule.scheduleName, 'Edited Meeting'); - expect(submission.schedule.scheduleTime, DateTime(2027, 3, 21, 10, 30)); + expect( + submission.schedule.scheduleTime, + DateTime.utc(2027, 3, 21, 10, 30), + ); expect(submission.schedule.moveTime, const Duration(minutes: 45)); expect( submission.schedule.scheduleSpareTime, @@ -370,7 +511,13 @@ void main() { (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, ); + final reviewReady = bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); bloc.add(const ScheduleFormCreated()); + final review = (await reviewReady).timeReview!; + expect(createScheduleFormSubmissionUseCase.submissions, isEmpty); + bloc.add(ScheduleFormTimeReviewConfirmed(review)); await submitDone; final submission = createScheduleFormSubmissionUseCase.submissions.single; diff --git a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart index 11572ccf..327cda39 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart @@ -35,7 +35,15 @@ void main() { maxAvailableTime: const Duration(minutes: 20), previousScheduleName: 'Previous', ).props, - [date, time, 9 * 60 * 60, const Duration(minutes: 20), 'Previous'], + [ + '', + false, + date, + time, + 9 * 60 * 60, + const Duration(minutes: 20), + 'Previous', + ], ); expect(const ScheduleFormPlaceNameChanged(placeName: 'Office').props, [ 'Office', @@ -88,6 +96,25 @@ void main() { expect(updated.isValid, isTrue); }); + test('offset omission preserves selection and explicit null clears it', () { + final state = ScheduleFormState( + id: 'draft', + occurrenceOffsetSeconds: 32400, + ); + expect( + state.copyWith(scheduleName: 'Rename').occurrenceOffsetSeconds, + 32400, + ); + expect( + state.copyWith(occurrenceOffsetSeconds: null).occurrenceOffsetSeconds, + isNull, + ); + expect( + state.copyWith(occurrenceOffsetSeconds: -18000).occurrenceOffsetSeconds, + -18000, + ); + }); + test('ScheduleFormState creates schedule entity from valid form fields', () { final state = ScheduleFormState( id: 'schedule-1', diff --git a/test/presentation/schedule_create/bloc/schedule_time_review_sqlite_integration_test.dart b/test/presentation/schedule_create/bloc/schedule_time_review_sqlite_integration_test.dart new file mode 100644 index 00000000..03b3c040 --- /dev/null +++ b/test/presentation/schedule_create/bloc/schedule_time_review_sqlite_integration_test.dart @@ -0,0 +1,319 @@ +// Production Bloc -> use cases -> workflow -> aggregate -> actual SQLite. +// Only platform time/id and post-commit OS effects are controlled here. +import 'dart:async'; +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/domain/use-cases/schedule_save_workflow.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; + +class _UnusedLoad extends Fake implements LoadPreparationByScheduleIdUseCase {} + +class _UnusedPreparation extends Fake + implements GetPreparationByScheduleIdUseCase {} + +class _UnusedDefault extends Fake implements GetDefaultPreparationUseCase {} + +class _UnusedSchedule extends Fake implements GetScheduleByIdUseCase {} + +class _Effects extends Fake implements ScheduleMutationAlarmEffectsCoordinator { + int calls = 0; + bool succeeds = true; + @override + Future afterCommit() async { + calls++; + return succeeds; + } +} + +class _ReceiptBarrier extends QueryInterceptor { + bool armed = false; + final entered = Completer(); + final release = Completer(); + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await executor.runUpdate(statement, args); + if (armed && statement.contains('last_mutation_id')) { + armed = false; + entered.complete(); + await release.future; + } + return result; + } +} + +class _Fixture { + _Fixture({this.barrier}); + final _ReceiptBarrier? barrier; + late AppDatabase db; + late ScheduleAggregateRepositoryImpl repository; + late ScheduleFormBloc bloc; + final effects = _Effects(); + DateTime now = DateTime.utc(2026, 10, 1); + String device = 'America/New_York'; + int ids = 0; + + Future open() async { + final executor = NativeDatabase.memory(); + db = AppDatabase.forTesting( + barrier == null ? executor : executor.interceptWith(barrier!), + ); + repository = ScheduleAggregateRepositoryImpl( + db, + RecurringScheduleRepositoryImpl(db, now: () => now), + gate: LocalDataOperationGate(), + now: () => now, + ); + await db.userDao.putUser( + const UserEntity(id: localProfileId, spareTime: Duration.zero, note: ''), + ); + final load = LoadScheduleFormDraftUseCase.withOverrides( + _UnusedLoad(), + _UnusedPreparation(), + _UnusedDefault(), + _UnusedSchedule(), + now: () => now, + newId: () => 'synthetic-${++ids}', + timeZoneId: () async => device, + aggregate: repository, + ); + final workflow = ScheduleSaveWorkflow(repository, effects); + bloc = ScheduleFormBloc( + load, + CreateScheduleFormSubmissionUseCase(workflow), + UpdateScheduleFormSubmissionUseCase(workflow), + aggregate: repository, + now: () => now, + ); + } + + Future send( + ScheduleFormEvent event, + bool Function(ScheduleFormState) accepts, + ) async { + final result = bloc.stream + .firstWhere(accepts) + .timeout(const Duration(seconds: 10)); + bloc.add(event); + await result; + // Let the event handler's finally retire its pending owner before next input. + await Future.delayed(Duration.zero); + } + + Future draft(int offset) async { + await send( + const ScheduleFormCreateRequested(), + (s) => s.status == ScheduleFormStatus.success, + ); + await send( + const ScheduleFormScheduleNameChanged( + scheduleName: 'Synthetic fold appointment', + ), + (s) => s.scheduleName != null, + ); + await send( + const ScheduleFormPlaceNameChanged(placeName: 'Synthetic place'), + (s) => s.placeName != null, + ); + await send( + const ScheduleFormMoveTimeChanged(moveTime: Duration.zero), + (s) => s.moveTime != null, + ); + await send( + ScheduleFormPreparationChanged( + preparation: PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'fixture-step', + preparationName: 'Prepare', + preparationTime: Duration(minutes: 10), + ), + ], + ), + ), + (s) => s.totalPreparationTime == const Duration(minutes: 10), + ); + final civil = DateTime.utc(2026, 11, 1, 1, 30, 1, 123, 456); + await send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'America/New_York', + timeZoneExplicitlySelected: true, + occurrenceOffsetSeconds: offset, + ), + (s) => s.scheduleTime == civil && s.occurrenceOffsetSeconds == offset, + ); + } + + Future revision() async => + (await db.select(db.users).getSingle()).dataRevision; + Future close() async { + if (barrier != null && !barrier!.release.isCompleted) { + barrier!.release.complete(); + } + await bloc.close(); + await db.close(); + } +} + +void main() { + for (final offset in [-14400, -18000]) { + test( + 'actual form saves explicit fold $offset once, cancel writes nothing, edit reload preserves civil', + () async { + final f = _Fixture(); + await f.open(); + addTearDown(f.close); + await f.draft(offset); + final civil = f.bloc.state.scheduleTime; + final before = await f.revision(); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + final firstReview = f.bloc.state.timeReview!; + expect( + firstReview.resolution.instantUtc, + DateTime.utc(2026, 11, 1, offset == -14400 ? 5 : 6, 30, 1, 123, 456), + ); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(f.effects.calls, 0); + await f.send( + const ScheduleFormReviewDismissed(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.idle, + ); + expect(f.bloc.state.scheduleTime, civil); + expect(await f.revision(), before); + expect(f.effects.calls, 0); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + final review = f.bloc.state.timeReview!; + expect(identical(review, firstReview), isFalse); + await f.send( + ScheduleFormTimeReviewConfirmed(review), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + final id = f.bloc.state.id; + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 1); + f.device = 'Asia/Tokyo'; + await f.send( + ScheduleFormEditRequested(scheduleId: id), + (s) => s.status == ScheduleFormStatus.success, + ); + expect(f.bloc.state.scheduleTime, civil); + expect(f.bloc.state.timeZoneId, 'America/New_York'); + expect(f.bloc.state.occurrenceOffsetSeconds, offset); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 1); + }, + ); + } + + test( + 'real Bloc authority crossing preparation start after SQL receipt write rolls back all data and effects', + () async { + final barrier = _ReceiptBarrier(); + final f = _Fixture(barrier: barrier); + await f.open(); + addTearDown(f.close); + await f.draft(-14400); + final before = await f.revision(); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + final review = f.bloc.state.timeReview!; + barrier.armed = true; + final failed = f.send( + ScheduleFormTimeReviewConfirmed(review), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + await barrier.entered.future.timeout(const Duration(seconds: 10)); + f.now = review.preparationStartUtc; + barrier.release.complete(); + await failed; + expect(f.bloc.state.saveFailure, ScheduleSaveFailure.conflict); + expect(await f.db.select(f.db.schedules).get(), isEmpty); + expect(await f.db.select(f.db.places).get(), isEmpty); + expect(await f.db.select(f.db.preparationSchedules).get(), isEmpty); + expect(await f.revision(), before); + expect(f.effects.calls, 0); + // Restore a valid synthetic clock and require a fresh user review. + f.now = DateTime.utc(2026, 10, 1); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + final fresh = f.bloc.state.timeReview!; + expect(identical(fresh, review), isFalse); + await f.send( + ScheduleFormTimeReviewConfirmed(fresh), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 1); + }, + ); + + test( + 'double confirmation plus delivery retry commits one row and one revision', + () async { + final f = _Fixture(); + await f.open(); + addTearDown(f.close); + await f.draft(-18000); + final before = await f.revision(); + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ); + final review = f.bloc.state.timeReview!; + f.effects.succeeds = false; + final pending = f.send( + ScheduleFormTimeReviewConfirmed(review), + (s) => + s.submissionStatus == ScheduleFormSubmissionStatus.deliveryPending, + ); + f.bloc.add(ScheduleFormTimeReviewConfirmed(review)); + await pending; + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 1); + f.effects.succeeds = true; + await f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + expect(await f.db.select(f.db.schedules).get(), hasLength(1)); + expect(await f.revision(), before + 1); + expect(f.effects.calls, 2); + }, + ); +} diff --git a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart index 8afa5e61..bc9a2d85 100644 --- a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart +++ b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart @@ -184,7 +184,9 @@ void main() { id: 'schedule-1', place: PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Meeting', - scheduleTime: DateTime(2027, 3, 20, 9, 0), + scheduleTime: DateTime.utc(2027, 3, 20, 9, 0), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 30), isChanged: false, isStarted: false, @@ -197,16 +199,21 @@ void main() { loadScheduleFormDraftUseCase, createScheduleFormSubmissionUseCase, updateScheduleFormSubmissionUseCase, + now: () => DateTime.utc(2026, 9, 25), ); } ScheduleFormDraft draftFromSchedule({ + bool editing = false, bool preparationChanged = false, Duration? spareTime = const Duration(minutes: 10), Object? scheduleTime = _unset, }) { return ScheduleFormDraft( id: schedule.id, + originalSchedule: editing ? schedule : null, + timeZoneId: schedule.timeZoneId, + occurrenceOffsetSeconds: schedule.occurrenceOffsetSeconds, placeId: schedule.place.id, placeName: schedule.place.placeName, scheduleName: schedule.scheduleName, @@ -321,7 +328,7 @@ void main() { scheduleTime: initialDate, ); }, - editHandler: (_) async => draftFromSchedule(), + editHandler: (_) async => draftFromSchedule(editing: true), ); createScheduleFormSubmissionUseCase = SpyCreateScheduleFormSubmissionUseCase(); @@ -374,6 +381,119 @@ void main() { await getIt.reset(); }); + Future openActualReview( + WidgetTester tester, + ScheduleFormBloc bloc, + ) async { + await primeEditState(bloc); + final civil = DateTime.utc(2027, 3, 20, 9); + bloc.add( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'Asia/Seoul', + timeZoneExplicitlySelected: true, + occurrenceOffsetSeconds: 32400, + ), + ); + await tester.pump(); + await pumpSheet(tester, bloc); + bloc.add(const ScheduleFormUpdated()); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('schedule-time-review-confirm')), + findsOneWidget, + ); + expect( + bloc.state.submissionStatus, + ScheduleFormSubmissionStatus.timeReview, + ); + } + + testWidgets( + 'actual form listener cancels final time review while retaining the edited draft', + (tester) async { + var writes = 0; + updateScheduleFormSubmissionUseCase.handler = (_) async { + writes++; + }; + final bloc = buildBloc(); + addTearDown(bloc.close); + await openActualReview(tester, bloc); + final before = bloc.state; + expect(writes, 0); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + expect(bloc.state.submissionStatus, ScheduleFormSubmissionStatus.idle); + expect(bloc.state.scheduleTime, before.scheduleTime); + expect(bloc.state.timeZoneId, before.timeZoneId); + expect( + bloc.state.occurrenceOffsetSeconds, + before.occurrenceOffsetSeconds, + ); + expect(bloc.state.mutationId, before.mutationId); + expect(writes, 0); + }, + ); + + testWidgets( + 'actual form listener confirms once and closes only after save receipt', + (tester) async { + var writes = 0; + final committed = Completer(); + updateScheduleFormSubmissionUseCase.handler = (_) async { + writes++; + await committed.future; + }; + final bloc = buildBloc(); + addTearDown(() async { + if (!committed.isCompleted) { + committed.complete(); + } + await bloc.close(); + }); + await openActualReview(tester, bloc); + expect(writes, 0); + await tester.tap( + find.byKey(const ValueKey('schedule-time-review-confirm')), + ); + await tester.pump(const Duration(milliseconds: 300)); + expect(writes, 1); + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + committed.complete(); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleMultiPageForm), findsNothing); + expect(writes, 1); + }, + ); + + testWidgets( + 'old final review dialog cannot submit a replacement form owner', + (tester) async { + var writes = 0; + updateScheduleFormSubmissionUseCase.handler = (_) async { + writes++; + }; + final bloc = buildBloc(); + addTearDown(bloc.close); + await openActualReview(tester, bloc); + final oldOwner = bloc.formOwner; + bloc.add(const ScheduleFormCreateRequested()); + await tester.pumpAndSettle(); + expect(identical(oldOwner, bloc.formOwner), isFalse); + final replacementMutation = bloc.state.mutationId; + await tester.tap( + find.byKey(const ValueKey('schedule-time-review-confirm')), + ); + await tester.pumpAndSettle(); + expect(writes, 0); + expect(bloc.state.mutationId, replacementMutation); + expect(bloc.state.submissionStatus, ScheduleFormSubmissionStatus.idle); + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + }, + ); + testWidgets( 'final submit does not close sheet immediately while submitting', (tester) async { @@ -507,7 +627,8 @@ void main() { id: 'schedule-2', place: const PlaceEntity(id: 'place-2', placeName: 'Next Office'), scheduleName: 'Next Meeting', - scheduleTime: DateTime(2027, 3, 20, 9, 20), + scheduleTime: DateTime.utc(2027, 3, 20, 9, 20), + occurrenceOffsetSeconds: 0, moveTime: const Duration(minutes: 10), isChanged: false, isStarted: false, @@ -564,12 +685,12 @@ void main() { for (var i = 0; i < 3; i++) { await tester.tap(find.byType(TextField).first); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ElevatedButton, 'Cancel')); + await tester.tap(find.byKey(const ValueKey('civil-picker-cancel'))); await tester.pumpAndSettle(); await tester.tap(find.byType(TextField).at(1)); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ElevatedButton, 'Cancel')); + await tester.tap(find.byKey(const ValueKey('civil-picker-cancel'))); await tester.pumpAndSettle(); } diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart index 92496523..b5a1e10b 100644 --- a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; @@ -15,6 +16,116 @@ import 'package:on_time_front/presentation/schedule_create/schedule_date_time/in import 'package:on_time_front/presentation/shared/theme/theme.dart'; void main() { + for (final failOld in [false, true]) { + test( + 'obsolete adjacent ${failOld ? "failure" : "success"} cannot replace a newer selection', + () async { + final form = _FakeScheduleFormBloc(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final pending = >[]; + adjacent.respond = () { + final response = Completer(); + pending.add(response); + return response.future; + }; + final cubit = ScheduleDateTimeCubit( + form, + _FakeLoadAdjacentScheduleWithPreparationUseCase(), + adjacent, + ); + addTearDown(cubit.close); + await cubit.scheduleDateChanged(DateTime.utc(2030, 1, 2)); + final oldRequest = cubit.scheduleTimeChanged( + DateTime.utc(2030, 1, 2, 10), + ); + final newRequest = cubit.scheduleTimeChanged( + DateTime.utc(2030, 1, 2, 11), + ); + expect(pending.length, 2); + pending.last.complete( + AdjacentSchedulesWithPreparationEntity( + nextSchedule: _scheduleWithPreparation( + id: 'latest', + name: 'Latest conflict', + scheduleTime: DateTime.utc(2030, 1, 2, 11, 15), + preparationMinutes: 30, + ), + ), + ); + await newRequest; + expect(cubit.state.isOverlapping, isTrue); + final stableState = cubit.state; + final eventCount = form.addedEvents.length; + if (failOld) { + pending.first.completeError(StateError('old query failed')); + } else { + pending.first.complete( + const AdjacentSchedulesWithPreparationEntity(), + ); + } + await oldRequest; + expect(cubit.state, stableState); + expect(cubit.state.nextScheduleName, 'Latest conflict'); + expect(form.addedEvents.length, eventCount); + }, + ); + } + + test( + 'same bloc changing form owner ignores an old adjacent result and validation', + () async { + final form = _FakeScheduleFormBloc(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final pending = Completer(); + adjacent.respond = () => pending.future; + final cubit = ScheduleDateTimeCubit( + form, + _FakeLoadAdjacentScheduleWithPreparationUseCase(), + adjacent, + ); + addTearDown(cubit.close); + await cubit.scheduleDateChanged(DateTime.utc(2030, 1, 2)); + final oldRequest = cubit.scheduleTimeChanged( + DateTime.utc(2030, 1, 2, 10), + ); + form.replaceOwner(ScheduleFormState(id: 'different-draft')); + cubit.initialize(); + final stableState = cubit.state; + final eventCount = form.addedEvents.length; + pending.complete( + AdjacentSchedulesWithPreparationEntity( + nextSchedule: _scheduleWithPreparation( + id: 'old', + name: 'Old conflict', + scheduleTime: DateTime.utc(2030, 1, 2, 10, 15), + preparationMinutes: 30, + ), + ), + ); + await oldRequest; + expect(cubit.state, stableState); + expect(form.addedEvents.length, eventCount); + }, + ); + + test('civil seconds and microseconds decide the exact past boundary', () { + final value = DateTime.utc(2030, 1, 1, 12, 0, 59, 123, 456); + final state = ScheduleDateTimeState( + scheduleDate: ScheduleDateInputModel.dirty(value), + scheduleTime: ScheduleTimeInputModel.dirty(value), + selectedOccurrenceOffsetSeconds: 0, + ); + expect( + state.isPastScheduleTimeAt(DateTime.utc(2030, 1, 1, 12, 0, 30)), + isFalse, + ); + expect(state.isPastScheduleTimeAt(value), isFalse); + expect( + state.isPastScheduleTimeAt(value.add(const Duration(microseconds: 1))), + isTrue, + ); + }); + test('state combines selected date and time and clears overlap fields', () { final date = DateTime.now().add(const Duration(days: 3)); final time = DateTime(2026, 1, 1, 9, 30); @@ -30,7 +141,7 @@ void main() { expect( state.selectedScheduleDateTime, - DateTime(date.year, date.month, date.day, 9, 30), + DateTime.utc(date.year, date.month, date.day, 9, 30), ); expect(state.hasAnyOverlapMessage, isTrue); @@ -66,6 +177,7 @@ void main() { final past = DateTime.now().subtract(const Duration(days: 1)); final state = ScheduleDateTimeState( + selectedOccurrenceOffsetSeconds: 0, scheduleDate: ScheduleDateInputModel.dirty(past), scheduleTime: ScheduleTimeInputModel.dirty(past), isOverlapping: true, @@ -99,12 +211,12 @@ void main() { expect(loader.calls, [ ( - DateTime( + DateTime.utc( date.year, date.month, date.day, ).subtract(const Duration(days: 1)), - DateTime( + DateTime.utc( date.year, date.month, date.day, @@ -113,7 +225,7 @@ void main() { ]); expect( adjacent.calls.single.selectedDateTime, - DateTime(date.year, date.month, date.day, 9, 30), + DateTime.utc(date.year, date.month, date.day, 9, 30), ); expect( formBloc.addedEvents.whereType().map( @@ -164,12 +276,12 @@ void main() { expect(loader.calls, [ ( - DateTime( + DateTime.utc( scheduledAt.year, scheduledAt.month, scheduledAt.day, ).subtract(const Duration(days: 1)), - DateTime( + DateTime.utc( scheduledAt.year, scheduledAt.month, scheduledAt.day, @@ -389,6 +501,8 @@ ScheduleWithPreparationEntity _scheduleWithPreparation({ place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: name, scheduleTime: scheduleTime, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, moveTime: Duration.zero, isChanged: false, isStarted: false, @@ -412,7 +526,17 @@ class _FakeScheduleFormBloc implements ScheduleFormBloc { : _state = state ?? ScheduleFormState(id: 'current-schedule'); final addedEvents = []; - final ScheduleFormState _state; + ScheduleFormState _state; + Object _owner = Object(); + void replaceOwner(ScheduleFormState value) { + _state = value; + _owner = Object(); + } + + @override + Object get formOwner => _owner; + @override + bool ownsForm(Object owner) => identical(owner, _owner); @override ScheduleFormState get state => _state; @@ -462,6 +586,7 @@ class _FakeGetAdjacentSchedulesWithPreparationUseCase const AdjacentSchedulesWithPreparationEntity(); final calls = <_AdjacentCall>[]; bool throwsOnCall = false; + Future Function()? respond; @override Future call({ @@ -481,7 +606,7 @@ class _FakeGetAdjacentSchedulesWithPreparationUseCase if (throwsOnCall) { throw Exception('adjacent schedules unavailable'); } - return result; + return respond == null ? result : await respond!(); } @override diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart index f69f1b37..afb86e54 100644 --- a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart @@ -41,6 +41,65 @@ void main() { expect(formBloc.addedEvents.whereType(), isNotEmpty); }); + for (final phase in ['date', 'zone-list', 'zone-apply']) { + testWidgets( + 'old $phase modal cannot apply or validate a replacement form', + (tester) async { + final form = _FakeScheduleFormBloc( + ScheduleFormState( + id: 'original', + scheduleTime: DateTime.utc(2030, 1, 2, 10), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + ), + ); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final cubit = ScheduleDateTimeCubit( + form, + _FakeLoadAdjacentSchedulesWithPreparationUseCase(), + adjacent, + )..initialize(); + addTearDown(cubit.close); + await _pumpForm(tester, cubit: cubit); + if (phase == 'date') { + await tester.tap(find.byType(TextField).first); + } else { + await tester.tap(find.text('UTC')); + } + await tester.pumpAndSettle(); + if (phase != 'date') { + await tester.enterText(find.byType(TextField).last, 'Asia/Seoul'); + await tester.pumpAndSettle(); + if (phase == 'zone-apply') { + await tester.tap( + find.widgetWithText(ListTile, 'Seoul · Asia/Seoul'), + ); + await tester.pumpAndSettle(); + expect(find.text('Selection preview'), findsOneWidget); + } + } + form.replaceOwner(); + final before = cubit.state; + final validations = form.addedEvents.length; + final requests = adjacent.calls.length; + if (phase == 'date') { + await tester.tap(find.byKey(const ValueKey('civil-picker-confirm'))); + } else if (phase == 'zone-list') { + await tester.tap(find.widgetWithText(ListTile, 'Seoul · Asia/Seoul')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('time-zone-apply'))); + } else { + await tester.tap(find.byKey(const ValueKey('time-zone-apply'))); + } + await tester.pumpAndSettle(); + expect(cubit.state, before); + expect(form.addedEvents.length, validations); + expect(adjacent.calls.length, requests); + expect(find.text('Confirm time zone'), findsNothing); + }, + ); + } + testWidgets('formats selected date with Korean locale', (tester) async { final scheduledAt = DateTime(2026, 5, 15, 9, 30); final formBloc = _FakeScheduleFormBloc( @@ -75,10 +134,20 @@ void main() { await _pumpForm(tester, cubit: cubit); expect(find.textContaining('occurs twice'), findsOneWidget); - expect(find.text('First (UTC-04:00)'), findsOneWidget); - expect(find.text('Second (UTC-05:00)'), findsOneWidget); - - await tester.tap(find.text('Second (UTC-05:00)')); + expect(find.text('First occurrence (UTC-04:00)'), findsOneWidget); + expect(find.text('Second occurrence (UTC-05:00)'), findsOneWidget); + + final second = find.text('Second occurrence (UTC-05:00)'); + await tester.scrollUntilVisible( + second, + 160, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await Scrollable.ensureVisible(tester.element(second), alignment: .5); + await tester.pumpAndSettle(); + expect(second.hitTestable(), findsOneWidget); + await tester.tap(second); await tester.pump(); expect(cubit.state.selectedOccurrenceOffsetSeconds, -5 * 60 * 60); @@ -110,7 +179,17 @@ Future _pumpForm( class _FakeScheduleFormBloc implements ScheduleFormBloc { _FakeScheduleFormBloc(this._state); - final ScheduleFormState _state; + ScheduleFormState _state; + Object _owner = Object(); + @override + Object get formOwner => _owner; + @override + bool ownsForm(Object owner) => identical(owner, _owner); + void replaceOwner() { + _owner = Object(); + _state = ScheduleFormState(id: 'replacement'); + } + final addedEvents = []; @override diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_time_zone_picker_contract_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_time_zone_picker_contract_test.dart new file mode 100644 index 00000000..e64f57f0 --- /dev/null +++ b/test/presentation/schedule_create/schedule_date_time/schedule_time_zone_picker_contract_test.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_time_zone_picker.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import 'package:on_time_front/presentation/shared/time/time_zone_catalog.dart'; + +class _Fixture { + String device = 'Asia/Tokyo'; + String draftZone = 'Asia/Seoul'; + bool current = true; + int applied = 0; + late final controller = DeviceTimeZoneController( + readZone: () async => device, + ); + + Future mount( + WidgetTester tester, { + String language = 'en', + bool narrow = false, + }) async { + await controller.refresh(); + if (narrow) { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + await tester.pumpWidget( + DeviceTimeZoneScope( + controller: controller, + child: MaterialApp( + locale: Locale(language), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(narrow ? 2.5 : 1)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + final selected = await showScheduleTimeZonePicker( + context: context, + currentZone: draftZone, + civil: DateTime.utc(2031, 1, 2, 9, 0, 1, 123, 456), + offsetSeconds: 32400, + isCurrent: () => current, + ); + if (selected != null) { + draftZone = selected; + applied++; + } + }, + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + } + + Future search(WidgetTester tester, String query) async { + final finder = find.byKey(const ValueKey('time-zone-search')); + await tester.scrollUntilVisible( + finder, + 100, + scrollable: find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.enterText(finder, query); + await tester.pumpAndSettle(); + } + + Future choose(WidgetTester tester, String label) async { + final finder = find.text(label); + await tester.scrollUntilVisible( + finder, + 100, + scrollable: find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ) + .first, + ); + await Scrollable.ensureVisible(tester.element(finder), alignment: 0.5); + await tester.pumpAndSettle(); + expect(finder.hitTestable(), findsOneWidget); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + controller.dispose(); + } +} + +void main() { + testWidgets( + 'unknown saved zone and unavailable device remain explicit until UTC is chosen', + (tester) async { + final f = _Fixture() + ..device = 'Missing/Device' + ..draftZone = 'Vendor/Removed'; + try { + await f.mount(tester); + expect( + find.textContaining('device time zone is unavailable'), + findsOneWidget, + ); + expect(find.textContaining('Vendor/Removed'), findsOneWidget); + expect(f.draftZone, 'Vendor/Removed'); + expect(f.applied, 0); + expect( + tester + .widget( + find.byKey(const ValueKey('time-zone-apply')), + ) + .onPressed, + isNull, + ); + await f.search(tester, 'UTC'); + await f.choose( + tester, + '${TimeZoneCatalog.cityName('UTC', 'en')} · UTC', + ); + expect(f.draftZone, 'Vendor/Removed'); + await tester.tap(find.byKey(const ValueKey('time-zone-apply'))); + await tester.pumpAndSettle(); + expect(f.draftZone, 'UTC'); + expect(f.applied, 1); + } finally { + await f.dispose(tester); + } + }, + ); + + testWidgets( + 'empty search explains recovery and clearing search preserves the draft', + (tester) async { + final f = _Fixture(); + try { + await f.mount(tester); + await f.search(tester, 'fixture impossible zone'); + expect(find.textContaining('No matching time zones'), findsOneWidget); + await tester.tap(find.byTooltip('Clear search')); + await tester.pumpAndSettle(); + expect(find.textContaining('No matching time zones'), findsNothing); + expect( + tester + .widget(find.byKey(const ValueKey('time-zone-search'))) + .controller! + .text, + isEmpty, + ); + expect(f.draftZone, 'Asia/Seoul'); + expect(f.applied, 0); + } finally { + await f.dispose(tester); + } + }, + ); + + test( + 'offline catalog supports Korean city, normalized English and full IANA names', + () { + expect( + TimeZoneCatalog.search(' new_YORK '), + contains('America/New_York'), + ); + expect(TimeZoneCatalog.search('뉴욕'), contains('America/New_York')); + expect( + TimeZoneCatalog.search('ASIA/KATHMANDU'), + contains('Asia/Kathmandu'), + ); + expect(TimeZoneCatalog.search('서울'), contains('Asia/Seoul')); + expect(TimeZoneCatalog.search('impossible fixture zone'), isEmpty); + }, + ); + + testWidgets( + 'selection previews civil fields and device equivalent before explicit apply', + (tester) async { + final f = _Fixture(); + try { + await f.mount(tester); + expect( + tester + .widget( + find.byKey(const ValueKey('time-zone-apply')), + ) + .onPressed, + isNull, + ); + await f.search(tester, 'new york'); + await f.choose(tester, 'New York · America/New_York'); + expect(f.draftZone, 'Asia/Seoul'); + expect(f.applied, 0); + expect(find.textContaining('America/New_York'), findsWidgets); + expect(find.textContaining('123456'), findsWidgets); + await tester.tap(find.byKey(const ValueKey('time-zone-apply'))); + await tester.pumpAndSettle(); + expect(f.draftZone, 'America/New_York'); + expect(f.applied, 1); + expect(tester.takeException(), isNull); + } finally { + await f.dispose(tester); + } + }, + ); + + for (final back in [false, true]) { + testWidgets( + '${back ? 'system back' : 'cancel'} discards preview without applying to draft', + (tester) async { + final f = _Fixture(); + try { + await f.mount(tester); + await f.search(tester, 'Kathmandu'); + await f.choose(tester, 'Kathmandu · Asia/Kathmandu'); + if (back) { + await tester.binding.handlePopRoute(); + } else { + await tester.tap(find.text('Cancel')); + } + await tester.pumpAndSettle(); + expect(f.draftZone, 'Asia/Seoul'); + expect(f.applied, 0); + expect(find.byKey(const ValueKey('time-zone-apply')), findsNothing); + } finally { + await f.dispose(tester); + } + }, + ); + } + + testWidgets('retired form owner cannot receive a picker result', ( + tester, + ) async { + final f = _Fixture(); + try { + await f.mount(tester); + await f.search(tester, 'Kathmandu'); + await f.choose(tester, 'Kathmandu · Asia/Kathmandu'); + f.current = false; + await tester.tap(find.byKey(const ValueKey('time-zone-apply'))); + await tester.pumpAndSettle(); + expect(f.draftZone, 'Asia/Seoul'); + expect(f.applied, 0); + } finally { + await f.dispose(tester); + } + }); + + testWidgets('device zone observation never replaces chosen draft candidate', ( + tester, + ) async { + final f = _Fixture(); + try { + await f.mount(tester); + await f.search(tester, 'Kathmandu'); + await f.choose(tester, 'Kathmandu · Asia/Kathmandu'); + f.device = 'America/New_York'; + await f.controller.refresh(); + await tester.pumpAndSettle(); + expect(find.textContaining('America/New_York'), findsWidgets); + expect(f.draftZone, 'Asia/Seoul'); + await tester.tap(find.byKey(const ValueKey('time-zone-apply'))); + await tester.pumpAndSettle(); + expect(f.draftZone, 'Asia/Kathmandu'); + expect(f.applied, 1); + } finally { + await f.dispose(tester); + } + }); + + for (final language in ['ko', 'en']) { + testWidgets( + '$language narrow large-text picker remains usable with keyboard insets', + (tester) async { + final f = _Fixture(); + try { + await f.mount(tester, language: language, narrow: true); + tester.view.viewInsets = const FakeViewPadding(bottom: 250); + addTearDown(tester.view.resetViewInsets); + await f.search(tester, 'Kathmandu'); + await f.choose( + tester, + '${language == 'ko' ? '카트만두' : 'Kathmandu'} · Asia/Kathmandu', + ); + expect(tester.takeException(), isNull); + final apply = find.byKey(const ValueKey('time-zone-apply')); + expect(apply.hitTestable(), findsOneWidget); + await tester.tap(apply); + await tester.pumpAndSettle(); + expect(f.draftZone, 'Asia/Kathmandu'); + expect(tester.takeException(), isNull); + } finally { + await f.dispose(tester); + } + }, + ); + } +} diff --git a/test/presentation/schedule_create/schedule_time_save_review_dialog_test.dart b/test/presentation/schedule_create/schedule_time_save_review_dialog_test.dart new file mode 100644 index 00000000..d807e3b5 --- /dev/null +++ b/test/presentation/schedule_create/schedule_time_save_review_dialog_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_time_save_review.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_time_save_review_dialog.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; + +void main() { + for (final language in ['ko', 'en']) { + for (final confirm in [false, true]) { + testWidgets( + '$language actual final dialog exposes before/after at320px text2.5 and returns $confirm', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = DeviceTimeZoneController( + readZone: () async => 'Asia/Tokyo', + ); + await controller.refresh(); + final original = ScheduleEntity( + id: 'dialog-fixture', + place: const PlaceEntity( + id: 'fixture-place', + placeName: 'Synthetic', + ), + scheduleName: 'Synthetic appointment', + scheduleTime: DateTime.utc(2031, 1, 2, 23, 0, 1, 123, 456), + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + final proposed = original.copyWith( + timeZoneId: 'America/New_York', + occurrenceOffsetSeconds: -18000, + ); + final now = DateTime.utc(2030); + final resolution = ScheduleTimeResolver.resolve( + proposed, + nowUtc: now, + ); + final review = ScheduleTimeSaveReview( + original: original, + proposed: proposed, + resolution: resolution, + baseline: null, + reviewedAtUtc: now, + preparationStartUtc: resolution.instantUtc!, + rulesIdentity: TimeZoneRules.loadedIdentity, + formOwner: Object(), + draftRevision: 1, + editing: true, + ); + bool? result; + var replies = 0; + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + DeviceTimeZoneScope( + controller: controller, + child: MaterialApp( + locale: Locale(language), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: + AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: const TextScaler.linear(2.5), + alwaysUse24HourFormat: true, + ), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showScheduleTimeSaveReview( + context: context, + review: review, + ); + replies++; + }, + child: const Text('Review'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Review')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(ScheduleZonedTime), findsNWidgets(2)); + expect(find.textContaining('Asia/Seoul'), findsOneWidget); + expect(find.textContaining('America/New_York'), findsOneWidget); + expect(find.textContaining('Asia/Tokyo'), findsNWidgets(2)); + expect( + find.textContaining('2031-01-03T04:00:01.123456Z'), + findsOneWidget, + ); + final after = find.byType(ScheduleZonedTime).last; + await tester.ensureVisible(after); + await tester.pumpAndSettle(); + final label = tester.getSemantics(after).label; + expect(label, contains('America/New_York')); + expect(label, contains('Asia/Tokyo')); + expect(label, contains('123456')); + expect(replies, 0); + final action = confirm + ? find.byKey(const ValueKey('schedule-time-review-confirm')) + : find.text(language == 'ko' ? '취소' : 'Cancel'); + expect(action.hitTestable(), findsOneWidget); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(result, confirm); + expect(replies, 1); + expect(tester.takeException(), isNull); + expect(original.timeZoneId, 'Asia/Seoul'); + expect(proposed.timeZoneId, 'America/New_York'); + } finally { + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + controller.dispose(); + semantics.dispose(); + } + }, + ); + } + } +} diff --git a/test/presentation/schedule_create/time_correction/schedule_time_correction_screen_test.dart b/test/presentation/schedule_create/time_correction/schedule_time_correction_screen_test.dart new file mode 100644 index 00000000..3a555410 --- /dev/null +++ b/test/presentation/schedule_create/time_correction/schedule_time_correction_screen_test.dart @@ -0,0 +1,466 @@ +import 'package:on_time_front/domain/entities/time_correction_conflict.dart'; +import 'dart:async'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_time_correction.dart'; +import 'package:on_time_front/domain/repositories/schedule_time_correction_repository.dart'; +import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; +import 'package:on_time_front/domain/use-cases/schedule_time_correction_workflow.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_time_correction_screen.dart'; + +class _Repository implements ScheduleTimeCorrectionRepository { + final values = {}; + final commands = []; + Future Function(String)? reader; + Future Function(ScheduleTimeCorrectionCommand)? + choiceReader; + ScheduleSaveFailure? failure; + Object? error; + @override + Future review(String id) async => + reader == null ? values[id]! : reader!(id); + @override + Future reviewChoice( + ScheduleTimeCorrectionCommand command, + ) async => choiceReader != null + ? choiceReader!(command) + : TimeCorrectionConflictProof( + conflicts: [], + through: command.civil.toUtcCarrier(), + workUnits: 0, + ); + @override + Future confirm( + ScheduleTimeCorrectionCommand command, + ) async { + commands.add(command); + if (error != null) throw error!; + if (failure != null) throw ScheduleSaveRejected(failure!); + return ScheduleSaveReceipt( + scheduleId: command.review.snapshot.schedule.id, + mutationId: command.mutationId, + generation: 0, + changed: true, + ); + } + + @override + bool isCurrent(ScheduleSaveReceipt receipt) => true; +} + +class _Effects extends Fake implements ScheduleMutationAlarmEffectsCoordinator { + int calls = 0; + bool complete = true; + @override + Future afterCommit() async { + calls++; + return complete; + } +} + +ScheduleTimeCorrectionReview _review({ + String id = 'one', + String zone = 'UTC', + DateTime? civil, + int? offset = 3600, + ScheduleSaveFailure? blockedBy, +}) { + final schedule = ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: 'Meeting $id', + scheduleTime: civil ?? DateTime.utc(2030, 1, 2, 10, 0, 59, 123, 456), + timeZoneId: zone, + occurrenceOffsetSeconds: offset, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: 'Keep this note', + ); + return ScheduleTimeCorrectionReview( + snapshot: ScheduleEditSnapshot( + schedule, + const PreparationEntity(preparationStepList: []), + const ScheduleEditBaseline( + store: 'store', + generation: 0, + revision: 1, + incarnation: 'row', + version: 1, + ), + ), + resolution: ScheduleTimeResolver.resolve( + schedule, + nowUtc: DateTime.utc(2029), + ), + ruleIdentity: 'test-rule', + validationNowUtc: DateTime.utc(2029), + blockedBy: blockedBy, + ); +} + +void main() { + late _Repository repository; + late _Effects effects; + late ScheduleTimeCorrectionWorkflow workflow; + var saved = 0; + setUp(() { + repository = _Repository()..values['one'] = _review(); + effects = _Effects(); + workflow = ScheduleTimeCorrectionWorkflow(repository, effects); + saved = 0; + }); + Future pump( + WidgetTester tester, { + String id = 'one', + String locale = 'en', + double scale = 1, + bool settle = true, + }) async { + await tester.pumpWidget( + MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MediaQuery( + data: MediaQueryData( + size: const Size(320, 568), + textScaler: TextScaler.linear(scale), + ), + child: Scaffold( + body: ScheduleTimeCorrectionScreen( + scheduleId: id, + workflow: workflow, + onSaved: () => saved++, + ), + ), + ), + ), + ); + if (settle) await tester.pumpAndSettle(); + } + + Future openConfirmation(WidgetTester tester) async { + final button = find.byKey(const Key('review-time-correction')); + await tester.scrollUntilVisible(button, 300); + await tester.pumpAndSettle(); + await tester.ensureVisible(button); + await tester.pumpAndSettle(); + expect(button.hitTestable(), findsOneWidget); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('confirm-time-correction')), findsOneWidget); + } + + Future confirm(WidgetTester tester) async { + await openConfirmation(tester); + await tester.tap(find.byKey(const Key('confirm-time-correction'))); + await tester.pumpAndSettle(); + } + + testWidgets( + 'known conflicts show the other appointment and cannot open confirmation', + (tester) async { + final other = _review(id: 'other').snapshot.schedule; + final at = other.scheduleTime; + repository.choiceReader = (command) async => TimeCorrectionConflictProof( + conflicts: [ + TimeCorrectionConflict( + slot: RecurrenceSlot( + civilTime: at, + instantUtc: at, + offsetSeconds: 0, + ordinal: 1, + ), + other: TimeCorrectionBusyInterval(other, at, at), + ), + ], + through: at, + workUnits: 1, + ); + await pump(tester); + final button = find.byKey(const Key('review-time-correction')); + await tester.scrollUntilVisible(button, 300); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('confirm-time-correction')), findsNothing); + expect(find.textContaining('Meeting other'), findsOneWidget); + expect(repository.commands, isEmpty); + }, + ); + testWidgets( + 'uncertain same-name appointments show civil zone reason and require explicit acknowledgement', + (tester) async { + repository.choiceReader = (command) async => TimeCorrectionConflictProof( + conflicts: [], + through: command.civil.toUtcCarrier(), + workUnits: 1, + possibleOverlaps: [ + TimeCorrectionPossibleOverlap( + id: 'first', + name: 'Same name', + reason: 'unknownZone', + originalCivil: DateTime.utc(2030, 1, 2, 8), + timeZoneId: 'Old/First', + ), + TimeCorrectionPossibleOverlap( + id: 'second', + name: 'Same name', + reason: 'ambiguous', + originalCivil: DateTime.utc(2030, 1, 2, 9), + timeZoneId: 'Old/Second', + ), + ], + ); + await pump(tester); + await openConfirmation(tester); + expect(find.textContaining('Old/First'), findsOneWidget); + expect(find.textContaining('Old/Second'), findsOneWidget); + expect( + find.textContaining('The saved time zone is unavailable'), + findsOneWidget, + ); + expect(find.textContaining('This time occurs twice'), findsOneWidget); + expect( + find.textContaining('new starts and alarms remain on hold'), + findsOneWidget, + ); + expect(repository.commands, isEmpty); + await tester.tap(find.byKey(const Key('confirm-time-correction'))); + await tester.pumpAndSettle(); + expect(repository.commands.single.acknowledgedUncertainIds, { + 'first', + 'second', + }); + }, + ); + testWidgets( + 'late conflict review cannot open a modal for a replacement owner', + (tester) async { + final pending = Completer(); + repository.values['two'] = _review(id: 'two'); + repository.choiceReader = (_) => pending.future; + await pump(tester); + final button = find.byKey(const Key('review-time-correction')); + await tester.scrollUntilVisible(button, 300); + await tester.tap(button); + await tester.pump(); + await pump(tester, id: 'two'); + pending.complete( + TimeCorrectionConflictProof( + conflicts: [], + through: DateTime.utc(2030), + workUnits: 0, + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('confirm-time-correction')), findsNothing); + expect(find.text('Meeting two'), findsOneWidget); + expect(repository.commands, isEmpty); + }, + ); + + testWidgets( + 'review and cancel never submit; explicit confirmation keeps precision', + (tester) async { + await pump(tester); + expect(repository.commands, isEmpty); + expect(find.textContaining('2030-01-02T10:00:59.123456Z'), findsWidgets); + await openConfirmation(tester); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(repository.commands, isEmpty); + await confirm(tester); + expect(repository.commands, hasLength(1)); + final command = repository.commands.single; + expect( + command.civil.toUtcCarrier(), + DateTime.utc(2030, 1, 2, 10, 0, 59, 123, 456), + ); + expect(command.offsetSeconds, 0); + expect(command.timeZoneId, 'UTC'); + expect(saved, 1); + expect(effects.calls, 1); + }, + ); + testWidgets('overlap requires explicit occurrence choice', (tester) async { + repository.values['one'] = _review( + zone: 'America/New_York', + civil: DateTime.utc(2030, 11, 3, 1, 30), + offset: null, + ); + await pump(tester); + expect( + tester + .widget(find.byKey(const Key('review-time-correction'))) + .onPressed, + isNull, + ); + final option = find.byKey(const ValueKey('correction-offset--18000')); + await tester.ensureVisible(option); + await tester.tap(option); + await tester.pumpAndSettle(); + expect(repository.commands, isEmpty); + await confirm(tester); + expect(repository.commands.single.offsetSeconds, -18000); + }); + testWidgets('gap proposal only changes the draft until confirmation', ( + tester, + ) async { + repository.values['one'] = _review( + zone: 'America/New_York', + civil: DateTime.utc(2030, 3, 10, 2, 30), + offset: null, + ); + await pump(tester); + expect( + tester + .widget(find.byKey(const Key('review-time-correction'))) + .onPressed, + isNull, + ); + final proposal = find.byKey(const Key('correction-gap-proposal')); + await tester.ensureVisible(proposal); + await tester.tap(proposal); + await tester.pumpAndSettle(); + expect(repository.commands, isEmpty); + await confirm(tester); + expect( + repository.commands.single.civil.toUtcCarrier(), + DateTime.utc(2030, 3, 10, 3), + ); + }); + testWidgets('pending delivery retries only the owner and never resaves', ( + tester, + ) async { + effects.complete = false; + await pump(tester); + await confirm(tester); + expect(repository.commands, hasLength(1)); + expect(saved, 0); + expect( + find.textContaining('Notification synchronization is still pending'), + findsOneWidget, + ); + effects.complete = true; + final retry = find.text('Retry notification synchronization'); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(repository.commands, hasLength(1)); + expect(effects.calls, 2); + expect(saved, 1); + }); + testWidgets( + 'protected review explains the record and has no confirmation authority', + (tester) async { + repository.values['one'] = _review( + blockedBy: ScheduleSaveFailure.protected, + ); + await pump(tester); + expect( + find.textContaining('Running or historical records'), + findsOneWidget, + ); + await tester.scrollUntilVisible( + find.byKey(const Key('review-time-correction')), + 300, + ); + expect( + tester + .widget( + find.byKey(const Key('review-time-correction')), + ) + .onPressed, + isNull, + ); + expect(repository.commands, isEmpty); + }, + ); + testWidgets('late review completion cannot replace another schedule', ( + tester, + ) async { + final pending = Completer(); + repository.values['two'] = _review(id: 'two'); + repository.reader = (id) => + id == 'one' ? pending.future : Future.value(repository.values[id]!); + await pump(tester, settle: false); + await tester.pump(); + await pump(tester, id: 'two'); + pending.complete(_review()); + await tester.pumpAndSettle(); + expect(find.text('Meeting two'), findsOneWidget); + expect(find.text('Meeting one'), findsNothing); + expect(repository.commands, isEmpty); + }); + testWidgets('conflict remains unsaved and requires a fresh review', ( + tester, + ) async { + repository.failure = ScheduleSaveFailure.conflict; + await pump(tester); + await confirm(tester); + expect(saved, 0); + expect(effects.calls, 0); + expect(find.text('Review latest record'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('review-time-correction'))) + .onPressed, + isNull, + ); + }); + testWidgets('an uncertain failure retries the same immutable intent', ( + tester, + ) async { + repository.error = StateError('storage result unavailable'); + await pump(tester); + await confirm(tester); + expect(repository.commands, hasLength(1)); + expect(saved, 0); + repository.error = null; + await confirm(tester); + expect(repository.commands, hasLength(2)); + expect( + identical(repository.commands.first, repository.commands.last), + isTrue, + ); + expect(saved, 1); + }); + testWidgets( + 'a confirmation from the previous owner cannot save a replacement', + (tester) async { + repository.values['two'] = _review(id: 'two'); + await pump(tester); + await openConfirmation(tester); + await pump(tester, id: 'two'); + await tester.tap(find.byKey(const Key('confirm-time-correction'))); + await tester.pumpAndSettle(); + expect(repository.commands, isEmpty); + expect(saved, 0); + expect(find.text('Meeting two'), findsOneWidget); + }, + ); + for (final locale in ['ko', 'en']) { + testWidgets( + '$locale review and confirmation fit a narrow screen with large text', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await pump(tester, locale: locale, scale: 2.5); + await openConfirmation(tester); + expect(tester.takeException(), isNull); + expect(repository.commands, isEmpty); + }, + ); + } +} diff --git a/test/presentation/schedule_create/u02_form_acceptance_integration_test.dart b/test/presentation/schedule_create/u02_form_acceptance_integration_test.dart new file mode 100644 index 00000000..362f557f --- /dev/null +++ b/test/presentation/schedule_create/u02_form_acceptance_integration_test.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import '../../helpers/u02_save_fixture.dart'; + +// The adjacent-query boundary is controlled and counted; draft ownership, +// selection, submission, writer, and persisted edit load are production code. +class _LoadAdjacent extends Fake + implements LoadAdjacentScheduleWithPreparationUseCase { + @override + Future call({ + required DateTime startDate, + required DateTime endDate, + }) async {} +} + +class _Adjacent extends Fake + implements GetAdjacentSchedulesWithPreparationUseCase { + final calls = []; + @override + Future call({ + required DateTime selectedDateTime, + String? currentScheduleId, + required DateTime startDate, + required DateTime endDate, + }) async { + calls.add(selectedDateTime); + return const AdjacentSchedulesWithPreparationEntity(); + } +} + +Future _mount( + WidgetTester tester, + U02SaveFixture f, + ScheduleDateTimeCubit cubit, +) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: BlocProvider.value( + value: f.bloc, + child: BlocProvider.value( + value: cubit, + child: const ScheduleDateTimeForm(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _reveal( + WidgetTester tester, + Finder target, { + Finder? scrollable, +}) async { + await tester.scrollUntilVisible( + target, + 120, + scrollable: scrollable ?? find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await Scrollable.ensureVisible(tester.element(target), alignment: .5); + await tester.pumpAndSettle(); + expect(target.hitTestable(), findsOneWidget); +} + +Future _pickZone( + WidgetTester tester, + String current, + String next, + String city, +) async { + final zoneTile = find.widgetWithText(ListTile, current); + await _reveal(tester, zoneTile); + await tester.tap(zoneTile); + await tester.pumpAndSettle(); + final scrollable = find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ) + .first; + final search = find.byKey(const ValueKey('time-zone-search')); + await _reveal(tester, search, scrollable: scrollable); + await tester.enterText(search, next); + await tester.pumpAndSettle(); + final candidate = find.widgetWithText(ListTile, '$city · $next'); + await _reveal(tester, candidate, scrollable: scrollable); + await tester.tap(candidate); + await tester.pumpAndSettle(); + final apply = find.byKey(const ValueKey('time-zone-apply')); + expect(apply.hitTestable(), findsOneWidget); + await tester.tap(apply); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets( + 'U02-08 actual gap proposal cancel and apply revalidate before any SQLite write', + (tester) async { + final f = U02SaveFixture(); + await tester.runAsync(f.open); + await tester.runAsync(() => f.draft(-18000)); + addTearDown(f.close); + final gap = DateTime.utc(2027, 3, 14, 2, 30); + await tester.runAsync( + () => f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: gap, + scheduleTime: gap, + timeZoneId: 'America/New_York', + occurrenceOffsetSeconds: -18000, + ), + (s) => s.scheduleTime == gap, + ), + ); + final adjacent = _Adjacent(); + final cubit = ScheduleDateTimeCubit(f.bloc, _LoadAdjacent(), adjacent) + ..initialize(); + addTearDown(cubit.close); + await _mount(tester, f, cubit); + final beforeRevision = await tester.runAsync(f.revision); + expect(cubit.state.isNonexistentCivilTime, isTrue); + expect(cubit.scheduleDateTimeSubmitted(), isFalse); + final proposal = find.widgetWithText( + TextButton, + 'Review next valid time', + ); + await _reveal(tester, proposal); + await tester.tap(proposal); + await tester.pumpAndSettle(); + expect(find.textContaining('2027-03-14T07:00:00.000Z'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(cubit.state.selectedScheduleDateTime, gap); + expect(cubit.scheduleDateTimeSubmitted(), isFalse); + final queriesBeforeApply = adjacent.calls.length; + await tester.tap(proposal); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'OK')); + await tester.pumpAndSettle(); + expect( + cubit.state.selectedScheduleDateTime, + DateTime.utc(2027, 3, 14, 3), + ); + expect(cubit.state.selectedOccurrenceOffsetSeconds, -14400); + expect(cubit.state.isNonexistentCivilTime, isFalse); + expect(adjacent.calls.length, greaterThan(queriesBeforeApply)); + expect(adjacent.calls.last, DateTime.utc(2027, 3, 14, 7)); + expect(cubit.state.isValid, isTrue); + expect(cubit.scheduleDateTimeSubmitted(), isTrue); + await tester.pump(); + expect(f.bloc.state.scheduleTime, DateTime.utc(2027, 3, 14, 3)); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + isEmpty, + ); + expect(await tester.runAsync(f.revision), beforeRevision); + expect(f.effects.calls, 0); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'U02-10 selected fold is invalidated by actual zone picker round trip', + (tester) async { + final f = U02SaveFixture(); + await tester.runAsync(f.open); + await tester.runAsync(() => f.draft(-18000)); + addTearDown(f.close); + final cubit = ScheduleDateTimeCubit(f.bloc, _LoadAdjacent(), _Adjacent()) + ..initialize(); + addTearDown(cubit.close); + await _mount(tester, f, cubit); + final civil = cubit.state.selectedScheduleDateTime; + expect(cubit.state.selectedOccurrenceOffsetSeconds, -18000); + await _pickZone(tester, 'America/New_York', 'Asia/Seoul', 'Seoul'); + expect(cubit.state.selectedScheduleDateTime, civil); + expect(cubit.state.selectedOccurrenceOffsetSeconds, 32400); + await _pickZone(tester, 'Asia/Seoul', 'America/New_York', 'New York'); + expect(cubit.state.selectedScheduleDateTime, civil); + expect(cubit.state.requiresOccurrenceChoice, isTrue); + expect(cubit.state.selectedOccurrenceOffsetSeconds, isNull); + expect(cubit.scheduleDateTimeSubmitted(), isFalse); + await tester.pump(); + expect(f.bloc.state.isValid, isFalse); + final first = find.text('First occurrence (UTC-04:00)'); + await _reveal(tester, first); + await tester.tap(first); + await tester.pumpAndSettle(); + expect(cubit.state.selectedOccurrenceOffsetSeconds, -14400); + expect(cubit.scheduleDateTimeSubmitted(), isTrue); + await tester.pump(); + expect(f.bloc.state.occurrenceOffsetSeconds, -14400); + await tester.runAsync( + () => f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ), + ); + expect(f.bloc.state.timeReview!.proposed.occurrenceOffsetSeconds, -14400); + expect( + f.bloc.state.timeReview!.resolution.instantUtc, + DateTime.utc(2026, 11, 1, 5, 30, 1, 123, 456), + ); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + isEmpty, + ); + expect(f.effects.calls, 0); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'U02-18 persisted alias survives actual edit picker search and cancel unchanged', + (tester) async { + final f = U02SaveFixture(); + await tester.runAsync(f.open); + await tester.runAsync(() => f.draft(-18000)); + addTearDown(f.close); + expect(TimeZoneRules.contains('US/Eastern'), isTrue); + final civil = f.bloc.state.scheduleTime!; + await tester.runAsync( + () => f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: civil, + scheduleTime: civil, + timeZoneId: 'US/Eastern', + occurrenceOffsetSeconds: -18000, + timeZoneExplicitlySelected: true, + ), + (s) => s.timeZoneId == 'US/Eastern', + ), + ); + await tester.runAsync( + () => f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ), + ); + await tester.runAsync( + () => f.send( + ScheduleFormTimeReviewConfirmed(f.bloc.state.timeReview!), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, + ), + ); + final id = f.bloc.state.id; + await tester.runAsync( + () => f.send( + ScheduleFormEditRequested(scheduleId: id), + (s) => + s.status == ScheduleFormStatus.success && + s.originalSchedule != null, + ), + ); + final beforeRows = await tester.runAsync( + () => f.db.select(f.db.schedules).get(), + ); + final revision = await tester.runAsync(f.revision); + final effects = f.effects.calls; + final cubit = ScheduleDateTimeCubit(f.bloc, _LoadAdjacent(), _Adjacent()) + ..initialize(); + addTearDown(cubit.close); + await _mount(tester, f, cubit); + final zoneTile = find.widgetWithText(ListTile, 'US/Eastern'); + await _reveal(tester, zoneTile); + await tester.tap(zoneTile); + await tester.pumpAndSettle(); + final scrollable = find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ) + .first; + final search = find.byKey(const ValueKey('time-zone-search')); + await _reveal(tester, search, scrollable: scrollable); + await tester.enterText(search, 'America/New_York'); + await tester.pumpAndSettle(); + final candidate = find.widgetWithText( + ListTile, + 'New York · America/New_York', + ); + await _reveal(tester, candidate, scrollable: scrollable); + await tester.tap(candidate); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(cubit.state.timeZoneId, 'US/Eastern'); + expect(cubit.state.selectedScheduleDateTime, civil); + expect(cubit.state.selectedOccurrenceOffsetSeconds, -18000); + expect(f.bloc.state.timeZoneId, 'US/Eastern'); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + beforeRows, + ); + expect(await tester.runAsync(f.revision), revision); + expect(f.effects.calls, effects); + final reread = await tester.runAsync(() => f.aggregate.readForEdit(id)); + expect(reread!.schedule.timeZoneId, 'US/Eastern'); + expect(reread.schedule.scheduleTime, civil); + expect(reread.schedule.occurrenceOffsetSeconds, -18000); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/schedule_create/u02_form_authority_integration_test.dart b/test/presentation/schedule_create/u02_form_authority_integration_test.dart new file mode 100644 index 00000000..8b5af72c --- /dev/null +++ b/test/presentation/schedule_create/u02_form_authority_integration_test.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_save.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/components/schedule_multi_page_form.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; +import '../../helpers/u02_save_fixture.dart'; + +class _LoadAdjacent extends Fake + implements LoadAdjacentScheduleWithPreparationUseCase { + @override + Future call({ + required DateTime startDate, + required DateTime endDate, + }) async {} +} + +class _Adjacent extends Fake + implements GetAdjacentSchedulesWithPreparationUseCase { + bool hold = false; + final requests = + <(DateTime, Completer)>[]; + @override + Future call({ + required DateTime selectedDateTime, + String? currentScheduleId, + required DateTime startDate, + required DateTime endDate, + }) { + if (!hold) { + return Future.value(const AdjacentSchedulesWithPreparationEntity()); + } + final pending = Completer(); + requests.add((selectedDateTime, pending)); + return pending.future; + } +} + +void main() { + late U02SaveFixture f; + late _Adjacent adjacent; + late ScheduleDateTimeCubit dateCubit; + setUp(() async { + await getIt.reset(); + f = U02SaveFixture(); + await f.open(); + await f.draft(-14400); + final date = DateTime.utc(2030, 1, 2, 10); + await f.send( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: date, + scheduleTime: date, + timeZoneId: 'UTC', + timeZoneExplicitlySelected: true, + occurrenceOffsetSeconds: 0, + ), + (s) => s.timeZoneId == 'UTC', + ); + adjacent = _Adjacent(); + getIt.registerFactoryParam(( + form, + _, + ) { + dateCubit = ScheduleDateTimeCubit(form, _LoadAdjacent(), adjacent); + return dateCubit; + }); + }); + tearDown(() async { + for (final request in adjacent.requests) { + if (!request.$2.isCompleted) { + request.$2.complete(const AdjacentSchedulesWithPreparationEntity()); + } + } + await getIt.reset(); + await f.close(); + }); + + Future mount(WidgetTester tester) async { + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BlocProvider.value( + value: f.bloc, + child: ScheduleMultiPageForm( + onSaved: () => f.bloc.add(const ScheduleFormCreated()), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + 'same form owner cannot confirm an open time review after store generation replacement', + (tester) async { + await mount(tester); + final owner = f.bloc.formOwner; + final mutation = f.bloc.state.mutationId; + final revision = await tester.runAsync(f.revision); + await tester.runAsync( + () => f.send( + const ScheduleFormCreated(), + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.timeReview, + ), + ); + await tester.pumpAndSettle(); + final confirm = find.byKey( + const ValueKey('schedule-time-review-confirm'), + ); + expect(confirm.hitTestable(), findsOneWidget); + final generation = f.bloc.state.timeReview!.baseline!.generation; + // Exercise the real replacement-generation operation boundary, isolated to + // this fixture. This is not an actual backup picker or file replacement UI. + await tester.runAsync(() => f.gate.run(() async {}, replacesData: true)); + expect(f.gate.generation, generation + 1); + expect(identical(f.bloc.formOwner, owner), isTrue); + expect(f.bloc.state.mutationId, mutation); + final rejected = f.bloc.stream.firstWhere( + (s) => s.submissionStatus == ScheduleFormSubmissionStatus.failure, + ); + await tester.tap(confirm); + await tester.pump(const Duration(milliseconds: 250)); + await tester.runAsync(() => rejected.timeout(const Duration(seconds: 5))); + await tester.pumpAndSettle(); + expect(f.bloc.state.saveFailure, ScheduleSaveFailure.conflict); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + isEmpty, + ); + expect(await tester.runAsync(f.revision), revision); + expect(f.effects.calls, 0); + expect(f.bloc.state.mutationId, mutation); + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'late B zone adjacency cannot make conflicting C valid or enable the real form Next action', + (tester) async { + await mount(tester); + // Await the actual form validation stream after a real name edit, rather + // than assuming asynchronous initialization has reached the listener. + final ready = f.bloc.state.isValid + ? Future.value(f.bloc.state) + : f.bloc.stream.firstWhere((state) => state.isValid); + await tester.enterText( + find.byType(TextFormField).first, + 'Authority flow', + ); + await tester.pump(); + await tester.runAsync(() => ready.timeout(const Duration(seconds: 5))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(ScreenActions)).onAction, + isNotNull, + ); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(dateCubit.state.timeZoneId, 'UTC'); + final revision = await tester.runAsync(f.revision); + adjacent.hold = true; + final b = dateCubit.timeZoneSelected('America/New_York'); + final c = dateCubit.timeZoneSelected('Asia/Seoul'); + expect(adjacent.requests, hasLength(2)); + expect(adjacent.requests[0].$1, DateTime.utc(2030, 1, 2, 15)); + expect(adjacent.requests[1].$1, DateTime.utc(2030, 1, 2, 1)); + adjacent.requests[1].$2.complete( + AdjacentSchedulesWithPreparationEntity( + nextSchedule: ScheduleWithPreparationEntity( + id: 'conflicting-c', + place: const PlaceEntity(id: 'place', placeName: 'Fixture'), + scheduleName: 'C conflict', + scheduleTime: DateTime.utc(2030, 1, 2, 1, 10), + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: const Duration(minutes: 20), + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleNote: '', + preparation: const PreparationWithTimeEntity( + preparationStepList: [], + ), + ), + ), + ); + await tester.runAsync(() => c); + await tester.pumpAndSettle(); + expect(dateCubit.state.isOverlapping, isTrue); + expect(f.bloc.state.isValid, isFalse); + adjacent.requests[0].$2.complete( + const AdjacentSchedulesWithPreparationEntity(), + ); + await tester.runAsync(() => b); + await tester.pumpAndSettle(); + expect(dateCubit.state.timeZoneId, 'Asia/Seoul'); + expect(dateCubit.state.isOverlapping, isTrue); + expect(dateCubit.state.nextScheduleName, 'C conflict'); + expect(f.bloc.state.isValid, isFalse); + expect(dateCubit.scheduleDateTimeSubmitted(), isFalse); + expect( + tester.widget(find.byType(ScreenActions)).onAction, + isNull, + ); + expect( + await tester.runAsync(() => f.db.select(f.db.schedules).get()), + isEmpty, + ); + expect(await tester.runAsync(f.revision), revision); + expect(f.effects.calls, 0); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/shared/components/civil_date_time_picker_test.dart b/test/presentation/shared/components/civil_date_time_picker_test.dart new file mode 100644 index 00000000..f65cbec5 --- /dev/null +++ b/test/presentation/shared/components/civil_date_time_picker_test.dart @@ -0,0 +1,262 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/components/civil_date_time_picker.dart'; + +class _Result { + bool completed = false; + DateTime? value; +} + +Future<_Result> _open( + WidgetTester tester, { + required DateTime initial, + required bool dateOnly, + Locale locale = const Locale('en'), + double scale = 1, +}) async { + final result = _Result(); + await tester.pumpWidget( + MaterialApp( + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result.value = await showCivilDateTimePicker( + context: context, + initialCivil: initial, + dateOnly: dateOnly, + title: 'Schedule wall time', + ); + result.completed = true; + }, + child: const Text('Open picker'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + expect(result.completed, isFalse); + return result; +} + +Future _select(WidgetTester tester, String wheel, int index) async { + // Use the actual scrolling widget and notification path, never call a + // selection callback or a private state method to manufacture a result. + final picker = tester.widget( + find.byKey(ValueKey('civil-picker-$wheel')), + ); + picker.scrollController!.jumpToItem(index); + await tester.pumpAndSettle(); +} + +Future _confirm(WidgetTester tester) async { + final button = find.byKey(const ValueKey('civil-picker-confirm')); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); +} + +String _preview(WidgetTester tester) => tester + .widget(find.byKey(const ValueKey('civil-picker-preview'))) + .data!; + +void main() { + testWidgets( + 'choosing 02:30 on the New York gap date preserves civil fields', + (tester) async { + if (Platform.environment['TZ'] == 'America/New_York') { + expect( + DateTime(2026, 3, 8, 2, 30).hour, + 3, + reason: 'This process must exercise the real device DST gap', + ); + } + final result = await _open( + tester, + initial: DateTime.utc(2026, 3, 8, 1, 30, 4, 123, 456), + dateOnly: false, + ); + final hour = find.byKey(const ValueKey('civil-picker-hour')); + final picker = tester.widget(hour); + final gesture = await tester.startGesture(tester.getCenter(hour)); + await gesture.moveBy(Offset(0, -picker.itemExtent)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(_preview(tester), contains('02:30:04')); + expect( + result.completed, + isFalse, + reason: 'Scrolling cannot commit a draft', + ); + await _confirm(tester); + expect(result.completed, isTrue); + expect(result.value, DateTime.utc(2026, 3, 8, 2, 30, 4, 123, 456)); + expect(result.value!.isUtc, isTrue); + }, + ); + + testWidgets('choosing the date skipped by Apia preserves December 30', ( + tester, + ) async { + if (Platform.environment['TZ'] == 'Pacific/Apia') { + expect( + DateTime(2011, 12, 30, 12).day, + isNot(30), + reason: 'This process must exercise the real skipped device date', + ); + } + final result = await _open( + tester, + initial: DateTime.utc(2011, 12, 29, 12, 34, 56, 789, 123), + dateOnly: true, + ); + await _select(tester, 'day', 29); + expect(_preview(tester), contains('12/30/2011')); + expect(result.completed, isFalse); + await _confirm(tester); + expect(result.value, DateTime.utc(2011, 12, 30, 12, 34, 56, 789, 123)); + }); + + testWidgets( + 'shortening a leap year shows February 28 before explicit confirmation', + (tester) async { + final result = await _open( + tester, + initial: DateTime.utc(2024, 2, 29, 8, 9, 10, 111, 222), + dateOnly: true, + ); + await _select(tester, 'year', 2025 - 1); + expect(_preview(tester), contains('2/28/2025')); + final days = tester.widget( + find.byKey(const ValueKey('civil-picker-day')), + ); + expect(days.scrollController!.selectedItem, 27); + expect(result.completed, isFalse); + await _confirm(tester); + expect(result.value, DateTime.utc(2025, 2, 28, 8, 9, 10, 111, 222)); + }, + ); + + testWidgets( + 'month shortening and expanding keep the visible selected day consistent', + (tester) async { + final result = await _open( + tester, + initial: DateTime.utc(2024, 1, 31, 23, 59, 58, 777, 888), + dateOnly: true, + ); + await _select(tester, 'month', 1); + expect(_preview(tester), contains('2/29/2024')); + await _select(tester, 'month', 2); + expect(_preview(tester), contains('3/29/2024')); + await _select(tester, 'day', 30); + expect(_preview(tester), contains('3/31/2024')); + await _confirm(tester); + expect(result.value, DateTime.utc(2024, 3, 31, 23, 59, 58, 777, 888)); + }, + ); + + testWidgets('cancel discards the changed draft', (tester) async { + final initial = DateTime.utc(2026, 9, 25, 9); + final result = await _open(tester, initial: initial, dateOnly: false); + await _select(tester, 'minute', 42); + expect(_preview(tester), contains('09:42:00')); + expect(result.completed, isFalse); + await tester.tap(find.byKey(const ValueKey('civil-picker-cancel'))); + await tester.pumpAndSettle(); + expect(result.completed, isTrue); + expect(result.value, isNull); + expect(initial, DateTime.utc(2026, 9, 25, 9)); + }); + + testWidgets('system back dismisses a changed draft without a selection', ( + tester, + ) async { + final result = await _open( + tester, + initial: DateTime.utc(2026, 9, 25, 9), + dateOnly: true, + ); + await _select(tester, 'month', 11); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(result.completed, isTrue); + expect(result.value, isNull); + }); + + for (final year in [1, 9999]) { + testWidgets( + 'year $year remains selectable without overflowing the supported range', + (tester) async { + final result = await _open( + tester, + initial: DateTime.utc( + year == 1 ? 2 : 9998, + 12, + 31, + 23, + 59, + 59, + 999, + 999, + ), + dateOnly: true, + ); + await _select(tester, 'year', year - 1); + await _confirm(tester); + expect(result.value, DateTime.utc(year, 12, 31, 23, 59, 59, 999, 999)); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final locale in [const Locale('en'), const Locale('ko')]) { + for (final dateOnly in [true, false]) { + testWidgets( + '${locale.languageCode} ${dateOnly ? 'date' : 'time'} picker works at large text on a small screen', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final semantics = tester.ensureSemantics(); + try { + final initial = DateTime.utc(9999, 12, 31, 23, 59, 59, 123, 456); + final result = await _open( + tester, + initial: initial, + dateOnly: dateOnly, + locale: locale, + scale: 2.5, + ); + final label = dateOnly + ? (locale.languageCode == 'ko' ? '년' : 'Year') + : (locale.languageCode == 'ko' ? '시' : 'Hour'); + expect(find.bySemanticsLabel(RegExp(label)), findsWidgets); + expect(tester.takeException(), isNull); + await _confirm(tester); + expect(result.value, initial); + expect(tester.takeException(), isNull); + } finally { + // Flutter verifies handles before package:test tearDown callbacks. + semantics.dispose(); + } + }, + ); + } + } +} diff --git a/test/presentation/shared/router/notification_launch_widget_test.dart b/test/presentation/shared/router/notification_launch_widget_test.dart index 186232d5..74d1c7d5 100644 --- a/test/presentation/shared/router/notification_launch_widget_test.dart +++ b/test/presentation/shared/router/notification_launch_widget_test.dart @@ -1,3 +1,4 @@ +import '../../../helpers/nearest_query_fixture.dart'; import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:rxdart/rxdart.dart'; import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; @@ -304,7 +305,10 @@ class _Monthly extends Cubit class _Nearest implements GetNearestUpcomingScheduleUseCase { final subject = BehaviorSubject.seeded(null); @override - Stream call() => subject.stream; + Stream call({required NearestQueryKey key}) => + subject.stream.map((value) => nearestQueryFixture(value, key)); + @override + Future readActive() async => null; } class _Session implements SchedulePreparationSessionUseCase { diff --git a/test/presentation/shared/time/device_time_zone_scope_test.dart b/test/presentation/shared/time/device_time_zone_scope_test.dart new file mode 100644 index 00000000..86a81880 --- /dev/null +++ b/test/presentation/shared/time/device_time_zone_scope_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_state.dart'; + +void main() { + test( + 'late successful and failed reads cannot replace the latest zone', + () async { + final replies = >[]; + final controller = DeviceTimeZoneController( + readZone: () { + final response = Completer(); + replies.add(response); + return response.future; + }, + ); + addTearDown(controller.dispose); + final old = controller.refresh(); + final latest = controller.refresh(); + replies[1].complete('Asia/Tokyo'); + await latest; + replies[0].complete('Asia/Seoul'); + await old; + expect(controller.value, const DeviceTimeZoneState.known('Asia/Tokyo')); + final failed = controller.refresh(); + final newest = controller.refresh(); + replies[3].complete('America/New_York'); + await newest; + replies[2].completeError(StateError('old plugin failure')); + await failed; + expect( + controller.value, + const DeviceTimeZoneState.known('America/New_York'), + ); + }, + ); + + test( + 'unavailable and disposal do not fabricate UTC or accept an old read', + () async { + final response = Completer(); + final controller = DeviceTimeZoneController( + readZone: () => response.future, + ); + final pending = controller.refresh(); + controller.dispose(); + response.complete('Asia/Seoul'); + await pending; + final invalid = DeviceTimeZoneController( + readZone: () async => 'Missing/Zone', + ); + addTearDown(invalid.dispose); + await invalid.refresh(); + expect(invalid.value, const DeviceTimeZoneState.unavailable()); + }, + ); + + testWidgets('paused mount and controller replacement do not start polling', ( + tester, + ) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + var firstReads = 0, secondReads = 0; + final first = DeviceTimeZoneController( + readZone: () async { + firstReads++; + return 'Asia/Seoul'; + }, + ); + final second = DeviceTimeZoneController( + readZone: () async { + secondReads++; + return 'Asia/Tokyo'; + }, + ); + addTearDown(first.dispose); + addTearDown(second.dispose); + Widget host(DeviceTimeZoneController controller) => + DeviceTimeZoneScope(controller: controller, child: const SizedBox()); + await tester.pumpWidget(host(first)); + await tester.pump(const Duration(minutes: 2)); + await tester.pumpWidget(host(second)); + await tester.pump(const Duration(minutes: 2)); + expect(firstReads, 0); + expect(secondReads, 0); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(secondReads, 1); + await tester.pump(const Duration(minutes: 1)); + expect(secondReads, 2); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets( + 'pause resume and replacing scope ignore earlier plugin completion', + (tester) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + final replies = >[]; + final first = DeviceTimeZoneController( + readZone: () { + final reply = Completer(); + replies.add(reply); + return reply.future; + }, + ); + final second = DeviceTimeZoneController( + readZone: () async => 'Asia/Tokyo', + ); + addTearDown(first.dispose); + addTearDown(second.dispose); + Widget host(DeviceTimeZoneController controller) => DeviceTimeZoneScope( + controller: controller, + child: MaterialApp( + home: Builder( + builder: (context) => Text( + DeviceTimeZoneScope.stateOf(context).identifier ?? 'unavailable', + ), + ), + ), + ); + await tester.pumpWidget(host(first)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + replies[1].complete('America/New_York'); + await tester.pump(); + expect(find.text('America/New_York'), findsOneWidget); + await tester.pumpWidget(host(second)); + await tester.pump(); + replies[0].complete('Asia/Seoul'); + await tester.pump(); + expect(find.text('Asia/Tokyo'), findsOneWidget); + expect(first.value, const DeviceTimeZoneState.known('America/New_York')); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/shared/time/zoned_recurrence_review_contract_test.dart b/test/presentation/shared/time/zoned_recurrence_review_contract_test.dart new file mode 100644 index 00000000..f733090d --- /dev/null +++ b/test/presentation/shared/time/zoned_recurrence_review_contract_test.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_review_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_time_choice_sheet.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; + +ScheduleEntity _schedule(String id, DateTime civil) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'p', placeName: 'Synthetic place'), + scheduleName: id, + scheduleTime: civil, + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); + +Future _mount(WidgetTester tester, Widget child, String language) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = DeviceTimeZoneController( + readZone: () async => 'America/New_York', + ); + await controller.refresh(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + await tester.pumpWidget( + DeviceTimeZoneScope( + controller: controller, + child: MaterialApp( + locale: Locale(language), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: const TextScaler.linear(2.5), + alwaysUse24HourFormat: true, + ), + child: child!, + ), + home: Scaffold(body: child), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _reveal(WidgetTester tester, Finder finder) async { + await tester.scrollUntilVisible( + finder, + 180, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await Scrollable.ensureVisible(tester.element(finder), alignment: .5); + await tester.pumpAndSettle(); + expect(finder.hitTestable(), findsOneWidget); +} + +void main() { + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: DateTime.utc(2030, 1, 2, 9), + timeZoneId: 'Asia/Seoul', + count: 5, + ); + final slots = const RecurrenceEngine() + .expand(rule, through: DateTime.utc(2031)) + .slots; + final occurrences = { + for (final slot in slots) + slot.key: RecurrencePreviewOccurrence( + _schedule('slot-${slot.ordinal}', slot.civilTime), + slot.instantUtc.subtract(const Duration(minutes: 10)), + ), + }; + final form = ScheduleFormState( + id: 'draft', + scheduleName: 'Synthetic recurring appointment', + scheduleTime: rule.start, + timeZoneId: rule.timeZoneId, + occurrenceOffsetSeconds: 32400, + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + recurrenceRule: rule, + ); + + for (final language in ['ko', 'en']) { + testWidgets( + '$language recurring review exposes every reviewed occurrence and preparation at320 text2.5', + (tester) async { + await _mount( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: slots, + skipped: [], + occurrences: occurrences, + totalOccurrences: 5, + ), + form: form, + ), + language, + ); + expect(tester.takeException(), isNull); + await _reveal( + tester, + find.textContaining(slots.first.instantUtc.toIso8601String()), + ); + final first = tester.widgetList( + find.byType(ScheduleZonedTime), + ); + expect( + first.any((w) => w.resolution.instantUtc == slots.first.instantUtc), + isTrue, + ); + expect( + first.any( + (w) => + w.resolution.instantUtc == + occurrences[slots.first.key]!.preparationStartUtc, + ), + isTrue, + ); + final all = find.byKey(const ValueKey('all-reviewed-occurrences')); + await _reveal(tester, all); + await tester.tap(all); + await tester.pumpAndSettle(); + final lastInstant = find.textContaining( + slots.last.instantUtc.toIso8601String(), + ); + await _reveal(tester, lastInstant); + expect(find.textContaining('America/New_York'), findsWidgets); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + '$language repeated-time choices expose both exact UTC instants at320 text2.5', + (tester) async { + await _mount( + tester, + RecurrenceTimeChoiceSheet( + date: DateTime.utc(2030, 11, 3, 1, 30), + timeZoneId: 'America/New_York', + ), + language, + ); + expect(tester.takeException(), isNull); + for (final instant in [ + DateTime.utc(2030, 11, 3, 5, 30), + DateTime.utc(2030, 11, 3, 6, 30), + ]) { + await _reveal(tester, find.textContaining(instant.toIso8601String())); + expect(tester.takeException(), isNull); + } + }, + ); + } + + testWidgets( + 'conflict review retains each selectable real zone and occurrence', + (tester) async { + final conflict = _schedule('conflicting appointment', slots[1].civilTime); + await _mount( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: slots, + skipped: [], + occurrences: occurrences, + conflicts: [RecurrenceConflict(slot: slots[1], other: conflict)], + ), + form: form, + ), + 'en', + ); + expect(tester.takeException(), isNull); + final checkbox = find.byKey( + ValueKey('review-occurrence-${slots[1].key}'), + ); + await tester.scrollUntilVisible( + checkbox, + 160, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + final preview = tester.widget( + find.descendant(of: checkbox, matching: find.byType(ScheduleZonedTime)), + ); + expect(preview.timeZoneId, 'Asia/Seoul'); + expect(preview.resolution.instantUtc, slots[1].instantUtc); + }, + ); + testWidgets('detached review preserves its original zone and exact instant', ( + tester, + ) async { + final detached = _schedule( + 'Detached original', + DateTime.utc(2030, 1, 9, 9), + ); + await _mount( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: const [], + skipped: const [], + detached: [detached], + ), + form: form, + ), + 'en', + ); + expect(tester.takeException(), isNull); + final time = find.textContaining( + detached.occurrenceInstantUtc.toIso8601String(), + ); + await _reveal(tester, time); + expect(find.textContaining('Asia/Seoul'), findsOneWidget); + expect(find.textContaining('America/New_York'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'obsolete explicit draft offset remains changed in persistent conflict preview', + (tester) async { + await _mount( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: slots, + skipped: const [], + persistentConflict: true, + ), + form: form.copyWith(occurrenceOffsetSeconds: 0), + ), + 'en', + ); + final preview = find.byType(ScheduleZonedTime); + await tester.scrollUntilVisible( + preview, + 180, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + final widget = tester.widget(preview); + expect(widget.resolution.status, ScheduleTimeResolutionStatus.changed); + expect(widget.resolution.instantUtc, isNull); + expect(tester.takeException(), isNull); + }, + ); + + for (final zone in ['UTC', 'Removed/Zone']) { + testWidgets( + 'preparation preview handles $zone rule/range failure without replacing its reviewed instant', + (tester) async { + final original = _schedule( + 'Boundary commitment', + DateTime.utc(1), + ).copyWith(timeZoneId: zone, occurrenceOffsetSeconds: 0); + final slot = RecurrenceSlot( + civilTime: DateTime.utc(1), + instantUtc: DateTime.utc(1), + offsetSeconds: 0, + ordinal: 1, + ); + final preparation = DateTime.utc(0, 12, 31, 23); + await _mount( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: [slot], + skipped: const [], + occurrences: { + slot.key: RecurrencePreviewOccurrence(original, preparation), + }, + ), + form: form, + ), + 'en', + ); + expect(tester.takeException(), isNull); + final warning = find.textContaining( + 'preparation start cannot be displayed', + ); + await _reveal(tester, warning); + expect( + find.textContaining(preparation.toIso8601String()), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/presentation/shared/time/zoned_schedule_presentation_contract_test.dart b/test/presentation/shared/time/zoned_schedule_presentation_contract_test.dart new file mode 100644 index 00000000..3baec343 --- /dev/null +++ b/test/presentation/shared/time/zoned_schedule_presentation_contract_test.dart @@ -0,0 +1,375 @@ +// Independent U02 display contracts. These use supplied commitment facts and +// actual bundled device-zone conversion, never the host's current offset. +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/l10n/app_localizations_en.dart'; +import 'package:on_time_front/l10n/app_localizations_ko.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_state.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time_text.dart'; +import 'package:on_time_front/presentation/shared/time/zoned_schedule_presentation.dart'; + +ZonedSchedulePresentation project({ + required String civil, + required String zone, + DateTime? instant, + String device = 'UTC', + ScheduleTimeResolution? resolution, + DeviceTimeZoneState? deviceState, +}) => ZonedSchedulePresentation.from( + civil: CivilDateTime.parse(civil), + scheduleTimeZoneId: zone, + resolution: + resolution ?? + ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: instant, + ), + deviceTimeZone: deviceState ?? DeviceTimeZoneState.known(device), +); + +void main() { + setUpAll(() async { + await initializeDateFormatting('en'); + await initializeDateFormatting('ko'); + }); + + test('Seoul commitment still names Tokyo when both offsets are +09:00', () { + final value = project( + civil: '2031-01-02T09:00:00', + zone: 'Asia/Seoul', + instant: DateTime.utc(2031, 1, 2), + device: 'Asia/Tokyo', + ); + expect(value.showDeviceEquivalent, isTrue); + expect(value.hasDifferentDeviceDate, isFalse); + expect(value.original.timeZoneId, 'Asia/Seoul'); + expect(value.device!.timeZoneId, 'Asia/Tokyo'); + expect(value.original.civil, value.device!.civil); + expect(value.original.offsetSeconds, 32400); + expect(value.device!.offsetSeconds, 32400); + final words = ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ); + expect(words.original, contains('Asia/Seoul')); + expect(words.device, contains('Asia/Tokyo')); + expect(words.semanticsLabel, contains('Asia/Tokyo')); + }); + + test('same named device zone has no redundant secondary line', () { + final value = project( + civil: '2031-01-02T09:00:00', + zone: 'Asia/Seoul', + instant: DateTime.utc(2031, 1, 2), + device: 'Asia/Seoul', + ); + expect(value.showDeviceEquivalent, isFalse); + expect( + ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsKo(), + use24HourFormat: true, + ).device, + isNull, + ); + }); + + test('date-line display keeps both dates and commitment microseconds', () { + final value = project( + civil: '2031-01-02T09:00:01.123456', + zone: 'Asia/Seoul', + instant: DateTime.utc(2031, 1, 2, 0, 0, 1, 123, 456), + device: 'America/New_York', + ); + expect( + value.original.civil.toCivilIso8601String(), + '2031-01-02T09:00:01.123456', + ); + expect( + value.device!.civil.toCivilIso8601String(), + '2031-01-01T19:00:01.123456', + ); + expect(value.hasDifferentDeviceDate, isTrue); + final words = ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ); + expect(words.original, contains('1/2/2031')); + expect(words.device, contains('1/1/2031')); + expect(words.original, contains('09:00:01.123456')); + expect(words.device, contains('19:00:01.123456')); + }); + + test('device offset is for the occurrence, including each New York fold', () { + final first = project( + civil: '2026-11-01T05:30:00', + zone: 'UTC', + instant: DateTime.utc(2026, 11, 1, 5, 30), + device: 'America/New_York', + ); + final second = project( + civil: '2026-11-01T06:30:00', + zone: 'UTC', + instant: DateTime.utc(2026, 11, 1, 6, 30), + device: 'America/New_York', + ); + expect(first.device!.civil, second.device!.civil); + expect( + first.device!.civil.toCivilIso8601String(), + '2026-11-01T01:30:00.000', + ); + expect(first.device!.offsetSeconds, -14400); + expect(second.device!.offsetSeconds, -18000); + expect(first.instantUtc, isNot(second.instantUtc)); + expect( + ScheduleZonedTimeText.format( + presentation: first, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ).device, + contains('UTC-04:00'), + ); + expect( + ScheduleZonedTimeText.format( + presentation: second, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ).device, + contains('UTC-05:00'), + ); + }); + + for (final sample in [ + ( + zone: 'Asia/Kathmandu', + month: 1, + offset: 20700, + civil: '2031-01-02T05:45:00.000', + ), + ( + zone: 'Australia/Lord_Howe', + month: 1, + offset: 39600, + civil: '2031-01-02T11:00:00.000', + ), + ( + zone: 'Australia/Lord_Howe', + month: 7, + offset: 37800, + civil: '2031-07-02T10:30:00.000', + ), + ]) { + test( + 'device conversion retains fractional-hour rules for ${sample.zone} month ${sample.month}', + () { + final value = project( + civil: CivilDateTime.fromFields( + DateTime.utc(2031, sample.month, 2), + ).toCivilIso8601String(), + zone: 'UTC', + instant: DateTime.utc(2031, sample.month, 2), + device: sample.zone, + ); + expect(value.device!.offsetSeconds, sample.offset); + expect(value.device!.civil.toCivilIso8601String(), sample.civil); + }, + ); + } + + test( + 'historical explicit second offset is not reinterpreted by modern rules', + () { + final civil = CivilDateTime.parse('1900-01-02T12:00:03.000007'); + final instant = civil.atOffset(561); + final value = project( + civil: civil.toCivilIso8601String(), + zone: 'Europe/Paris', + resolution: ScheduleTimeResolution( + status: ScheduleTimeResolutionStatus.resolved, + instantUtc: instant, + isHistorical: true, + ), + ); + expect(value.original.offsetSeconds, 561); + expect(value.instantUtc, instant); + final words = ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ); + expect(words.original, contains('UTC+00:09:21')); + expect(words.original, contains('12:00:03.000007')); + expect(words.device, contains('11:50:42.000007')); + }, + ); + + for (final status in [ + ScheduleTimeResolutionStatus.historicalUncertain, + ScheduleTimeResolutionStatus.unknownZone, + ScheduleTimeResolutionStatus.nonexistent, + ScheduleTimeResolutionStatus.ambiguous, + ScheduleTimeResolutionStatus.changed, + ScheduleTimeResolutionStatus.invalid, + ]) { + test( + '$status does not display a proposed instant as an accepted commitment', + () { + final resolution = ScheduleTimeResolution( + status: status, + proposedInstantUtc: DateTime.utc(2031, 1, 2), + isHistorical: + status == ScheduleTimeResolutionStatus.historicalUncertain, + ); + final value = project( + civil: '2031-01-02T09:00:00', + zone: 'Asia/Seoul', + resolution: resolution, + ); + expect(value.instantUtc, isNull); + expect(value.device, isNull); + expect(value.original.offsetSeconds, isNull); + expect( + value.original.civil.toCivilIso8601String(), + '2031-01-02T09:00:00.000', + ); + final words = ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ); + expect(words.statusMessage, isNotEmpty); + expect(words.instant, isNull); + expect(words.device, isNull); + }, + ); + } + + test( + 'unique future null offset uses real resolver without persisting a guess', + () { + final row = ScheduleEntity( + id: 'original', + scheduleName: 'Synthetic appointment', + place: const PlaceEntity(id: 'place', placeName: 'Synthetic'), + scheduleTime: DateTime.utc(2031, 1, 2, 9), + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: null, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: '', + ); + final value = project( + civil: '2031-01-02T09:00:00', + zone: row.timeZoneId, + resolution: ScheduleTimeResolver.resolve( + row, + nowUtc: DateTime.utc(2030), + ), + ); + expect(value.instantUtc, DateTime.utc(2031, 1, 2)); + expect(value.original.offsetSeconds, 32400); + expect(row.occurrenceOffsetSeconds, isNull); + }, + ); + + for (final state in [ + const DeviceTimeZoneState.loading(), + const DeviceTimeZoneState.unavailable(), + const DeviceTimeZoneState.known('Removed/Zone'), + ]) { + test( + 'device ${state.status}/${state.identifier} never invents UTC equivalent', + () { + final value = project( + civil: '2031-01-02T09:00:00', + zone: 'Asia/Seoul', + instant: DateTime.utc(2031, 1, 2), + deviceState: state, + ); + expect(value.instantUtc, DateTime.utc(2031, 1, 2)); + expect(value.device, isNull); + expect(value.original.timeZoneId, 'Asia/Seoul'); + expect( + ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsKo(), + use24HourFormat: true, + ).statusMessage, + isNotEmpty, + ); + }, + ); + } + + for (final sample in [ + ( + civil: '0001-01-01T00:00:00', + instant: DateTime.utc(1), + device: 'America/New_York', + ), + ( + civil: '9999-12-31T23:59:59', + instant: DateTime.utc(9999, 12, 31, 23, 59, 59), + device: 'Asia/Seoul', + ), + ]) { + test('device year overflow preserves valid original ${sample.civil}', () { + final value = project( + civil: sample.civil, + zone: 'UTC', + instant: sample.instant, + device: sample.device, + ); + expect(value.original.civil, CivilDateTime.parse(sample.civil)); + expect(value.instantUtc, sample.instant); + expect(value.device, isNull); + expect(value.deviceTimeZone.identifier, sample.device); + expect( + value.deviceConversionStatus, + DeviceTimeConversionStatus.outOfRange, + ); + expect( + ScheduleZonedTimeText.format( + presentation: value, + l10n: AppLocalizationsEn(), + use24HourFormat: true, + ).statusMessage, + isNotEmpty, + ); + }); + } + + for (final locale in ['en', 'ko']) { + test( + '$locale follows explicit 12/24-hour preference without losing precision', + () { + final civil = CivilDateTime.parse('2031-01-02T13:04:05.000007'); + final twelve = ScheduleZonedTimeText.formatCivil( + civil, + locale: locale, + use24HourFormat: false, + ); + final twentyFour = ScheduleZonedTimeText.formatCivil( + civil, + locale: locale, + use24HourFormat: true, + ); + expect( + twentyFour, + contains(locale == 'ko' ? '13시 4분 5.000007초' : '13:04:05.000007'), + ); + expect(twelve, contains('1:04:05.000007')); + expect(twelve, contains(locale == 'en' ? 'PM' : '오후')); + expect(twentyFour, isNot(contains(locale == 'en' ? 'PM' : '오후'))); + }, + ); + } +} diff --git a/test/presentation/shared/time/zoned_schedule_surface_contract_test.dart b/test/presentation/shared/time/zoned_schedule_surface_contract_test.dart new file mode 100644 index 00000000..519f9cf4 --- /dev/null +++ b/test/presentation/shared/time/zoned_schedule_surface_contract_test.dart @@ -0,0 +1,138 @@ +// Actual home/calendar consumers, not a replacement test-only time widget. +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/calendar/component/schedule_detail.dart'; +import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; +import 'package:on_time_front/presentation/shared/time/device_time_zone_scope.dart'; +import 'package:on_time_front/presentation/shared/time/schedule_zoned_time.dart'; + +class _SyntheticIcons extends CachingAssetBundle { + @override + Future load(String key) async => ByteData.sublistView( + Uint8List.fromList( + utf8.encode( + '', + ), + ), + ); +} + +void main() { + for (final language in ['ko', 'en']) { + for (final device in ['Asia/Tokyo', 'America/New_York']) { + for (final home in [true, false]) { + testWidgets( + '${home ? 'actual home tile' : 'actual calendar detail'} $language $device exposes both zones at320px text2.5', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + final controller = DeviceTimeZoneController( + readZone: () async => device, + ); + await controller.refresh(); + final schedule = ScheduleEntity( + id: 'surface-fixture', + place: const PlaceEntity( + id: 'fixture-place', + placeName: 'Synthetic place', + ), + scheduleName: 'Synthetic appointment', + scheduleTime: DateTime.utc(2031, 1, 2, 9, 0, 1, 123, 456), + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 32400, + moveTime: const Duration(minutes: 10), + scheduleSpareTime: null, + isChanged: false, + isStarted: false, + scheduleNote: '', + ); + try { + await tester.pumpWidget( + DefaultAssetBundle( + bundle: _SyntheticIcons(), + child: DeviceTimeZoneScope( + controller: controller, + child: MaterialApp( + locale: Locale(language), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: + AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: const TextScaler.linear(2.5), + alwaysUse24HourFormat: true, + ), + child: child!, + ), + home: Scaffold( + body: SingleChildScrollView( + child: home + ? TodaysScheduleTile( + schedule: schedule, + compact: true, + ) + : ScheduleDetail(schedule: schedule), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + expect(find.byType(ScheduleZonedTime), findsOneWidget); + final original = find.textContaining('Asia/Seoul'); + final equivalent = find.textContaining(device); + expect(original, findsOneWidget); + expect(equivalent, findsOneWidget); + for (final finder in [original, equivalent]) { + final text = tester.widget(finder); + expect( + text.maxLines, + isNull, + reason: + 'Full named zone and date must not be capped/ellipsized.', + ); + expect(text.overflow, isNot(TextOverflow.ellipsis)); + } + final label = tester + .getSemantics(find.byType(ScheduleZonedTime)) + .label; + expect(label, contains('Asia/Seoul')); + expect(label, contains(device)); + expect(label, contains('123456')); + if (device == 'America/New_York') { + expect( + label, + contains(language == 'en' ? '1/2/2031' : '2031. 1. 2.'), + ); + expect( + label, + contains(language == 'en' ? '1/1/2031' : '2031. 1. 1.'), + ); + } + expect(schedule.occurrenceOffsetSeconds, 32400); + expect(schedule.timeZoneId, 'Asia/Seoul'); + } finally { + // Retire real tile countdown and shared observation before test + // invariant checks. This is fixture teardown, not product logic. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + controller.dispose(); + semantics.dispose(); + } + }, + ); + } + } + } +} diff --git a/test/presentation/startup/damaged_store_restore_test.dart b/test/presentation/startup/damaged_store_restore_test.dart new file mode 100644 index 00000000..ace93297 --- /dev/null +++ b/test/presentation/startup/damaged_store_restore_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/domain/entities/local_reset_result.dart'; +import 'package:on_time_front/presentation/startup/screens/local_startup_gate.dart'; + +void main() { + testWidgets('damaged store offers backup recovery before normal DI', ( + tester, + ) async { + tester.binding.platformDispatcher.localesTestValue = const [Locale('en')]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + await tester.pumpWidget( + LocalStartupGate( + recovery: () async => throw StateError('Not selected yet'), + prepare: () async => throw const RestoreStoreUnavailable('private'), + ready: () => throw StateError('No product graph before recovery'), + retryReset: () async => + const LocalResetResult(intentRecorded: false, completed: {}), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Restore an OnTime Backup'), findsOneWidget); + expect(find.textContaining('private'), findsNothing); + }); +} diff --git a/test/presentation/startup/local_startup_store_recovery_test.dart b/test/presentation/startup/local_startup_store_recovery_test.dart new file mode 100644 index 00000000..b579d5d4 --- /dev/null +++ b/test/presentation/startup/local_startup_store_recovery_test.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/core/database/bootstrap_privacy_boundary.dart'; +import 'package:on_time_front/core/startup/startup_failure.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/core/startup/startup_dependency_scope.dart'; +import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; +import 'package:on_time_front/data/adapters/local_data_workflow_adapters.dart'; +import '../../core/database/local_reset_protocol_test.dart' as reset_fixtures; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/domain/entities/local_reset_result.dart'; +import 'package:on_time_front/presentation/startup/screens/local_startup_gate.dart'; + +void main() { + setUpAll(() async { + if (Platform.environment['ONTIME_D01_CAPTURE_DIR'] != null) { + final font = FontLoader('Pretendard') + ..addFont(rootBundle.load('assets/fonts/Pretendard-Regular.ttf')); + await font.load(); + } + }); + testWidgets( + 'unreadable pre-DI store keeps explicit reset reachable without DI', + (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale('en'); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + await tester.pumpWidget( + LocalStartupGate( + prepare: () async => + throw const RestoreStoreUnavailable('PRIVATE-KEY-PATH'), + ready: () => throw StateError('Normal app must not be constructed'), + beginReset: () async => + const LocalResetResult(intentRecorded: true, completed: {}), + retryReset: () async => + const LocalResetResult(intentRecorded: false, completed: {}), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('PRIVATE-KEY-PATH'), findsNothing); + expect(find.text('Reset all local data'), findsOneWidget); + }, + ); + for (final preserved in [true, false]) { + testWidgets( + 'bootstrap and privacy double failure preserves classified reset access: $preserved', + (tester) async { + tester.binding.platformDispatcher.localesTestValue = const [ + Locale('en'), + ]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + var bootstrapCalls = 0; + var cleanupCalls = 0; + await tester.pumpWidget( + LocalStartupGate( + prepare: () => startupStage( + StartupStage.lifecycle, + () => bootstrapWithPrivacyCleanup( + bootstrap: () async { + bootstrapCalls++; + if (preserved) throw const LocalStorePreservationRequired(); + throw StateError('PRIVATE-BOOTSTRAP'); + }, + cleanup: () async { + cleanupCalls++; + throw StateError('PRIVATE-CLEANUP'); + }, + ), + ), + ready: () => throw StateError('No normal app'), + beginReset: () => throw StateError('No implicit reset'), + retryReset: () => throw StateError('No invented intent'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text('Reset all local data'), + preserved ? findsOneWidget : findsNothing, + ); + expect( + find.text( + 'Privacy cleanup of previous temporary information is also incomplete.', + ), + findsOneWidget, + ); + expect(find.textContaining('PRIVATE'), findsNothing); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(bootstrapCalls, 2); + expect(cleanupCalls, 2); + }, + ); + } + for (final ko in [false, true]) { + testWidgets( + '${ko ? 'ko' : 'en'} pre-DI cancel preserves intent; confirmation reaches actual reset protocol', + (tester) async { + tester.binding.platformDispatcher.localeTestValue = Locale( + ko ? 'ko' : 'en', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + tester.binding.platformDispatcher.localesTestValue = [ + Locale(ko ? 'ko' : 'en'), + ]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final actions = reset_fixtures.FakeActions(); + final rig = reset_fixtures.Rig( + AlarmOwnershipJournal(MemoryAlarmJournalStore()), + actions, + ); + addTearDown(rig.owner.dispose); + final workflow = LocalResetWorkflow( + RecoveryResetAdapter(() => rig.run(begin: true)), + ); + final screenshotKey = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: screenshotKey, + child: LocalStartupGate( + prepare: () async => + throw const RestoreStoreUnavailable('PRIVATE'), + ready: () => throw StateError('DI forbidden'), + beginReset: workflow.call, + retryReset: () => rig.run(begin: false), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + Localizations.localeOf( + tester.element(find.byType(Scaffold)), + ).languageCode, + ko ? 'ko' : 'en', + ); + await _capture( + tester, + screenshotKey, + '${ko ? 'ko' : 'en'}-preservation', + ); + final reset = find.text(ko ? '로컬 데이터 초기화' : 'Reset all local data'); + await tester.ensureVisible(reset); + await tester.tap(reset); + await tester.pumpAndSettle(); + await _capture(tester, screenshotKey, '${ko ? 'ko' : 'en'}-confirm'); + await tester.tap(find.text(ko ? '취소' : 'Cancel')); + await tester.pumpAndSettle(); + expect(actions.performed, isEmpty); + expect(actions.marker, false); + expect((await rig.journal.read()).reset, ResetPhase.none); + await tester.tap(reset); + await tester.pumpAndSettle(); + await tester.tap(find.text(ko ? '모두 삭제' : 'Delete all')); + await tester.pumpAndSettle(); + expect(actions.performed.toSet(), ResetStep.values.toSet()); + expect( + find.text(ko ? '로컬 데이터 초기화 완료' : 'Local data reset complete'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final ko in [false, true]) { + for (final pending in [false, true]) { + testWidgets( + '${ko ? 'ko' : 'en'} ${pending ? 'waiting' : 'restore cleanup'} stays accessible at 200 percent', + (tester) async { + tester.binding.platformDispatcher.localesTestValue = [ + Locale(ko ? 'ko' : 'en'), + ]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final key = GlobalKey(); + final completion = Completer(); + final phase = ValueNotifier(StartupStage.store); + addTearDown(phase.dispose); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: LocalStartupGate( + stage: phase, + prepare: () => pending + ? completion.future + : Future.error(const RestoreRecoveryRequired('PRIVATE')), + ready: () => const MaterialApp(home: Text('ready')), + beginReset: () => throw StateError('new reset forbidden'), + retryReset: () => throw StateError('reset resume forbidden'), + ), + ), + ); + if (pending) { + await tester.pump(const Duration(seconds: 10)); + await tester.pump(); + } else { + await tester.pumpAndSettle(); + } + expect( + find.text(ko ? '암호화된 로컬 데이터 확인' : 'Checking encrypted local data'), + findsOneWidget, + ); + expect( + find.text(ko ? '로컬 데이터 초기화' : 'Reset all local data'), + findsNothing, + ); + expect( + find.text(ko ? '다시 시도' : 'Try again'), + pending ? findsNothing : findsOneWidget, + ); + expect(find.textContaining('PRIVATE'), findsNothing); + await _capture( + tester, + key, + '${ko ? 'ko' : 'en'}-${pending ? 'waiting' : 'restore-cleanup'}', + ); + expect(tester.takeException(), isNull); + if (pending) { + completion.complete(); + await tester.pumpAndSettle(); + } + await tester.pumpWidget(const SizedBox()); + }, + ); + } + } + + testWidgets( + 'known restore cleanup exposes retry only, never new reset or a second import', + (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale('en'); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + var retries = 0; + await tester.pumpWidget( + LocalStartupGate( + prepare: () async { + retries++; + throw const RestoreRecoveryRequired('PRIVATE'); + }, + ready: () => throw StateError('DI forbidden'), + beginReset: () => throw StateError('New reset forbidden'), + retryReset: () => throw StateError('Reset resume forbidden'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Restored data has pending cleanup.'), findsOneWidget); + expect(find.text('Reset all local data'), findsNothing); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(retries, 2); + }, + ); + + testWidgets( + 'failed cleanup blocks new graph and retry cleans the same attempt first', + (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale('en'); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + var prepares = 0; + var graphs = 0; + var cleanupCalls = 0; + var failCleanup = true; + await tester.pumpWidget( + LocalStartupGate( + prepare: () async { + prepares++; + }, + ready: () { + if (++graphs == 1) throw StateError('partial graph'); + return const MaterialApp(home: Text('ready')); + }, + cleanupAttempt: () async { + cleanupCalls++; + if (failCleanup) throw const StartupCleanupIncomplete(); + }, + retryReset: () => throw StateError('reset forbidden'), + ), + ); + await tester.pumpAndSettle(); + expect(prepares, 1); + expect(graphs, 1); + expect(cleanupCalls, 1); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(prepares, 1); + expect(graphs, 1); + expect(cleanupCalls, 2); + failCleanup = false; + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(cleanupCalls, 3); + expect(prepares, 2); + expect(graphs, 2); + expect(find.text('ready'), findsOneWidget); + }, + ); + + testWidgets( + 'waiting threshold does not create retry or dispose the pending owner', + (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale('en'); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + final pending = Completer(); + var calls = 0; + await tester.pumpWidget( + LocalStartupGate( + prepare: () { + calls++; + return pending.future; + }, + ready: () => const MaterialApp(home: Text('ready')), + retryReset: () => throw StateError('reset forbidden'), + ), + ); + await tester.pump(const Duration(seconds: 10)); + expect(find.text('Startup is still in progress.'), findsOneWidget); + expect(find.text('Try again'), findsNothing); + expect(calls, 1); + pending.complete(); + await tester.pumpAndSettle(); + expect(find.text('ready'), findsOneWidget); + }, + ); +} + +Future _capture(WidgetTester tester, GlobalKey key, String name) async { + final directory = Platform.environment['ONTIME_D01_CAPTURE_DIR']; + if (directory == null) return; + final boundary = tester.renderObject(find.byKey(key)); + await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File( + '$directory/$name.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); +} diff --git a/test/presentation/startup/migration_recovery_integration_test.dart b/test/presentation/startup/migration_recovery_integration_test.dart new file mode 100644 index 00000000..82563405 --- /dev/null +++ b/test/presentation/startup/migration_recovery_integration_test.dart @@ -0,0 +1,101 @@ +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'package:on_time_front/presentation/startup/screens/local_startup_gate.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +void main() { + testWidgets( + 'actual future store reaches pre-DI recovery without normal app or alarms', + (tester) async { + tester.binding.platformDispatcher.localesTestValue = const [Locale('en')]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + final directory = Directory.systemTemp.createTempSync('d04-shell-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final file = File('${directory.path}/store.sqlite'); + final raw = sqlite.sqlite3.open(file.path); + raw.execute( + File('test/fixtures/database/schema_v4.sql').readAsStringSync(), + ); + raw.execute( + File('test/fixtures/database/populated_local.sql').readAsStringSync(), + ); + raw.userVersion = DatabaseSchemaContract.current + 1; + raw.dispose(); + final before = file.readAsBytesSync(); + final gate = LocalDataOperationGate(); + addTearDown(gate.dispose); + AppDatabase? attempt; + var normalCalls = 0; + var alarms = 0; + var attempts = 0; + Object? classified; + await tester.pumpWidget( + LocalStartupGate( + prepare: () async { + attempts++; + attempt = AppDatabase.forTesting(NativeDatabase(file)); + try { + await RestoreRuntimeIdentity().prepareStartup( + attempt!, + gate, + cleanupPlatform: () async { + alarms++; + }, + ); + alarms++; // Represents normal notification bootstrap after store authority. + } catch (error) { + classified = error; + rethrow; + } + }, + cleanupAttempt: () async { + await attempt?.close(); + attempt = null; + }, + ready: () { + normalCalls++; + return const SizedBox(); + }, + beginReset: () => throw StateError('No implicit reset'), + retryReset: () => throw StateError('No reset intent'), + ), + ); + await tester.pumpAndSettle(); + expect( + classified, + isA().having( + (e) => e.cause, + 'actual migration refusal', + isA(), + ), + ); + expect(find.text('Reset all local data'), findsOneWidget); + expect(normalCalls, 0); + expect(alarms, 0); + expect(file.readAsBytesSync(), before); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(attempts, 2); + expect(normalCalls, 0); + expect(alarms, 0); + expect(file.readAsBytesSync(), before); + final reopened = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + expect(reopened.userVersion, DatabaseSchemaContract.current + 1); + expect( + reopened.select('SELECT note FROM users').single['note'], + '합성 프로필 공백', + ); + reopened.dispose(); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/presentation/startup/recovery_restore_screen_test.dart b/test/presentation/startup/recovery_restore_screen_test.dart new file mode 100644 index 00000000..4c34a375 --- /dev/null +++ b/test/presentation/startup/recovery_restore_screen_test.dart @@ -0,0 +1,330 @@ +// Real recovery screen interactions. The fake port verifies UI ownership; +// actual persistence and operation single flight are tested by recovery service tests. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/ports/recovery_restore_port.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/startup/screens/recovery_restore_screen.dart'; + +class _Port implements RecoveryRestorePort { + int previews = 0; + int activations = 0; + int resumes = 0; + int aborts = 0; + Future Function()? previewAction; + Future Function()? activateAction; + Future Function()? resumeAction; + Future Function()? abortAction; + + @override + Future preview(String password) { + previews++; + return previewAction!(); + } + + @override + Future activate(BackupRestoreInput input) { + activations++; + return activateAction!(); + } + + @override + Future resume() { + resumes++; + return resumeAction!(); + } + + @override + Future abort() { + aborts++; + return abortAction!(); + } +} + +class _Candidate extends BackupRestoreInput { + int releases = 0; + bool failRelease = false; + @override + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: DateTime.utc(2030, 1, 2), + sourceAppVersion: '1.2.3', + sourcePlatform: 'android', + scheduleCount: 12, + templateCount: 3, + defaultPreparationStepCount: 4, + ); + @override + Future dispose() async { + releases++; + if (failRelease) throw StateError('private candidate path'); + } +} + +Widget _screen( + _Port port, { + String locale = 'en', + BackupRestoreReceipt? initial, + VoidCallback? cancelled, + VoidCallback? completed, +}) => MaterialApp( + locale: Locale(locale), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: RecoveryRestoreScreen( + port: () async => port, + initialReceipt: initial, + onCancelled: cancelled ?? () {}, + onCompleted: completed ?? () {}, + ), +); + +Future _choose(WidgetTester tester) async { + await tester.tap(find.text('Choose backup file')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(RecoveryPasswordDialog), findsOneWidget); + await tester.enterText(find.byType(TextField), 'portable backup password'); + await tester.tap(find.text('Continue')); + // The picker/preview may intentionally remain outstanding. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +void main() { + for (final locale in ['en', 'ko']) { + testWidgets( + '$locale unknown outcome keeps one live requery and no destructive choices', + (tester) async { + final pending = Completer(); + final port = _Port()..resumeAction = () => pending.future; + var exited = 0; + var completed = 0; + await tester.pumpWidget( + _screen( + port, + locale: locale, + initial: const BackupRestoreReceipt.uncertain(generation: 1), + cancelled: () => exited++, + completed: () => completed++, + ), + ); + await tester.pumpAndSettle(); + final next = locale == 'ko' ? '복구 계속' : 'Continue recovery'; + expect( + find.text(locale == 'ko' ? '백업 파일 선택' : 'Choose backup file'), + findsNothing, + ); + expect( + find.text(locale == 'ko' ? '이 복구 중단' : 'Stop this recovery'), + findsNothing, + ); + expect(find.text(locale == 'ko' ? '돌아가기' : 'Back'), findsNothing); + await tester.tap(find.text(next)); + await tester.pump(); + await tester.tap(find.text(next)); + await tester.pump(const Duration(seconds: 30)); + expect(port.resumes, 1); + expect(port.previews, 0); + expect(port.aborts, 0); + expect(port.activations, 0); + expect(exited, 0); + expect(completed, 0); + expect( + tester + .widget(find.widgetWithText(FilledButton, next)) + .onPressed, + isNull, + ); + pending.complete(const BackupRestoreReceipt.uncertain(generation: 1)); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.widgetWithText(FilledButton, next)) + .onPressed, + isNotNull, + ); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'unapplied recovery cancellation requires confirmation and waits for cleanup', + (tester) async { + final pending = Completer(); + final port = _Port()..abortAction = () => pending.future; + var normalApp = 0; + await tester.pumpWidget( + _screen( + port, + initial: BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: 2, + followUpPending: true, + ), + completed: () => normalApp++, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Stop this recovery')); + await tester.pumpAndSettle(); + expect( + find.textContaining('Keep the original data and key'), + findsOneWidget, + ); + await tester.tap(find.text('Back')); + await tester.pumpAndSettle(); + expect(port.aborts, 0); + await tester.tap(find.text('Stop this recovery')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Stop recovery')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(port.aborts, 1); + expect(find.text('Choose backup file'), findsNothing); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Continue recovery'), + ) + .onPressed, + isNull, + ); + pending.complete( + BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: 2, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Choose backup file'), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Choose backup file'), + ) + .onPressed, + isNotNull, + ); + expect(normalApp, 0); // Keeping the damaged original is not a ready app. + }, + ); + + testWidgets( + 'late preview after screen disposal releases candidate without activation', + (tester) async { + final pending = Completer(); + final candidate = _Candidate(); + final port = _Port()..previewAction = () => pending.future; + await tester.pumpWidget(_screen(port)); + await tester.pumpAndSettle(); + await _choose(tester); + expect(port.previews, 1); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + pending.complete(candidate); + await tester.pumpAndSettle(); + expect(candidate.releases, 1); + expect(port.activations, 0); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'preview cancellation reports unknown original count and releases the candidate', + (tester) async { + final candidate = _Candidate(); + final port = _Port()..previewAction = () async => candidate; + await tester.pumpWidget(_screen(port)); + await tester.pumpAndSettle(); + await _choose(tester); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.textContaining( + '12 schedules · 3 templates · 4 default preparation steps', + ), + findsOneWidget, + ); + expect( + find.textContaining('The damaged original count is unknown.'), + findsOneWidget, + ); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(port.activations, 0); + expect(candidate.releases, 1); + expect(find.text('Choose backup file'), findsOneWidget); + }, + ); + + testWidgets( + 'failed candidate release keeps replacement and exit blocked without leaking cause', + (tester) async { + final candidate = _Candidate()..failRelease = true; + final port = _Port()..previewAction = () async => candidate; + await tester.pumpWidget(_screen(port)); + await tester.pumpAndSettle(); + await _choose(tester); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(candidate.releases, 1); + expect(find.textContaining('private candidate path'), findsNothing); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Choose backup file'), + ) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.widgetWithText(TextButton, 'Back')) + .onPressed, + isNull, + ); + expect(port.activations, 0); + }, + ); + + testWidgets( + 'applied recovery waits for restart and follow-up without importing again', + (tester) async { + final pending = Completer(); + final port = _Port()..resumeAction = () => pending.future; + var normalApp = 0; + await tester.pumpWidget( + _screen( + port, + initial: const BackupRestoreReceipt.recovery( + generation: 3, + phase: RecoveryFollowUp.awaitingNewProcess, + ), + completed: () => normalApp++, + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Fully close and reopen'), findsOneWidget); + expect(find.text('Stop this recovery'), findsNothing); + expect(find.text('Choose backup file'), findsNothing); + await tester.tap(find.text('Continue recovery')); + await tester.pump(); + expect(normalApp, 0); + pending.complete( + BackupRestoreReceipt( + disposition: BackupCommitDisposition.committed, + generation: 3, + ), + ); + await tester.pumpAndSettle(); + expect(normalApp, 1); + expect(port.previews, 0); + expect(port.activations, 0); + }, + ); +} diff --git a/test/presentation/startup/recovery_time_review_test.dart b/test/presentation/startup/recovery_time_review_test.dart new file mode 100644 index 00000000..c7faa39d --- /dev/null +++ b/test/presentation/startup/recovery_time_review_test.dart @@ -0,0 +1,554 @@ +import '../backup/backup_recurring_time_review_screen_test.dart' as recurring; +import 'package:on_time_front/presentation/backup/backup_recurring_time_review_screen.dart'; +// Real Recovery and time-review screens with fake ports. File/journal/crypto +// authority is covered separately by the Recovery service tests. +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/entities/backup_restore_selection.dart'; +import 'package:on_time_front/domain/entities/backup_time_review.dart'; +import 'package:on_time_front/domain/ports/backup_time_review_port.dart'; +import 'package:on_time_front/domain/ports/recovery_preclaim_cleanup_port.dart'; +import 'package:on_time_front/domain/ports/recovery_restore_port.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/backup/backup_time_review_screen.dart'; +import 'package:on_time_front/presentation/startup/screens/recovery_restore_screen.dart'; + +class Candidate extends BackupRestoreInput { + int releaseCalls = 0, releaseFailures = 0; + bool disposed = false; + @override + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: DateTime.utc(2030), + sourceAppVersion: '1.0', + sourcePlatform: 'android', + scheduleCount: 1, + templateCount: 0, + defaultPreparationStepCount: 0, + ); + @override + Future dispose() async { + releaseCalls++; + if (releaseFailures > 0) { + releaseFailures--; + throw StateError('private file'); + } + disposed = true; + } +} + +class Review extends BackupTimeReviewInput { + Review(this.ready); + final Candidate ready; + bool transferred = false, disposed = false; + int validations = 0; + @override + BackupTimeReviewSummary get summary => BackupTimeReviewSummary( + identity: 'review', + revision: 0, + validationNowUtc: DateTime.utc(2026), + rulesIdentity: 'rules', + issueCount: 0, + independentStructureChecked: true, + ); + @override + Future issues({String? cursor}) async => + BackupTimeReviewIssuePage([]); + @override + Future review(BackupTimeDraft draft) => + throw UnimplementedError(); + @override + Future choose(BackupTimeChoice choice) => throw UnimplementedError(); + @override + Future revalidate() async { + validations++; + transferred = true; + return ready; + } + + @override + Future dispose() async { + if (!transferred) disposed = true; + } +} + +class RecurringReview extends recurring.Candidate { + RecurringReview(this.ready) : super('recovery-recurrence'); + final Candidate ready; + int fullValidations = 0; + Completer? pendingFullValidation; + Object? validationFailure; + BackupTimeReviewInput? replacement; + @override + Future revalidate() async { + fullValidations++; + if (validationFailure != null) throw validationFailure!; + if (pendingFullValidation != null) return pendingFullValidation!.future; + if (replacement != null) return replacement!; + return choices.isEmpty ? this : ready; + } +} + +class Port + implements + RecoveryRestorePort, + BackupTimeReviewPort, + RecoveryPreclaimCleanupPort { + BackupRestoreSelection? selection; + Completer? pendingSelection; + Object? activationFailure; + int selections = 0, + previews = 0, + resumes = 0, + aborts = 0, + cleanupFailures = 0; + final activations = []; + final cleanupGenerations = []; + Completer? pendingCleanup; + @override + Future selectForRestore(String password) async { + selections++; + return pendingSelection == null ? selection : pendingSelection!.future; + } + + @override + Future preview(String password) async { + previews++; + throw StateError('Typed capability should be used'); + } + + @override + Future activate(BackupRestoreInput input) async { + activations.add(input); + if (activationFailure != null) throw activationFailure!; + return BackupRestoreReceipt.recovery( + generation: 7, + phase: RecoveryFollowUp.awaitingNewProcess, + ); + } + + @override + Future retryPreclaimCleanup(int generation) async { + cleanupGenerations.add(generation); + if (cleanupFailures > 0) { + cleanupFailures--; + throw StateError('private cleanup'); + } + if (pendingCleanup != null) await pendingCleanup!.future; + } + + @override + Future resume() async { + resumes++; + throw StateError('Unexpected confirmed resume'); + } + + @override + Future abort() async { + aborts++; + throw StateError('Unexpected confirmed abort'); + } +} + +class Host { + Host(this.port); + final Port port; + int factories = 0, exited = 0, completed = 0; + Future open( + WidgetTester tester, { + String locale = 'en', + double scale = 1, + }) async { + await tester.pumpWidget( + MaterialApp( + locale: Locale(locale), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: RecoveryRestoreScreen( + port: () async { + factories++; + if (factories > 1) { + throw StateError('Do not recreate a cleanup owner'); + } + return port; + }, + onCancelled: () => exited++, + onCompleted: () => completed++, + ), + ), + ); + await tester.pumpAndSettle(); + } +} + +Future tap(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + if (finder.evaluate().isEmpty) { + await tester.scrollUntilVisible( + finder, + 150, + scrollable: find.byType(Scrollable).first, + ); + } + await Scrollable.ensureVisible(tester.element(finder), alignment: .5); + await tester.pump(); + await tester.tap(finder); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +Future select(WidgetTester tester, {String locale = 'en'}) async { + await tap(tester, 'select-recovery-backup'); + await tester.enterText(find.byType(TextField), 'portable backup password'); + final next = find.text(locale == 'ko' ? '계속' : 'Continue'); + await tester.ensureVisible(next); + await tester.tap(next); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +Future activate(WidgetTester tester, {String locale = 'en'}) async { + await select(tester, locale: locale); + await tap(tester, 'confirm-recovery-replacement'); + await tester.pumpAndSettle(); +} + +const staleAfterCleanup = DataOperationException( + DataOperationFailure.stalePreview, + followUpPending: true, + generation: 7, +); + +void main() { + setUp(() => WidgetController.hitTestWarningShouldBeFatal = true); + tearDown(() => WidgetController.hitTestWarningShouldBeFatal = false); + testWidgets( + 'typed review must produce a fresh ready input before separate replacement confirmation', + (tester) async { + final ready = Candidate(); + final review = Review(ready); + final port = Port()..selection = review; + final host = Host(port); + await host.open(tester); + await select(tester); + expect(find.byType(BackupTimeReviewScreen), findsOneWidget); + expect(port.previews, 0); + expect(port.activations, isEmpty); + await tap(tester, 'revalidate-backup-time'); + // The parent operation remains busy while awaiting this modal choice. + await tester.pump(const Duration(milliseconds: 350)); + expect(review.validations, 1); + expect( + find.byKey(const Key('confirm-recovery-replacement')), + findsOneWidget, + ); + expect(port.activations, isEmpty); + expect(ready.disposed, isFalse); + await tap(tester, 'confirm-recovery-replacement'); + await tester.pumpAndSettle(); + expect(port.activations, [ready]); + expect(host.factories, 1); + expect(host.completed, 0); + expect(find.textContaining('Fully close and reopen'), findsOneWidget); + }, + ); + + testWidgets( + 'cancelled time review releases selection without activation or replacement dialog', + (tester) async { + final review = Review(Candidate()); + final port = Port()..selection = review; + await Host(port).open(tester); + await select(tester); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + expect(review.disposed, isTrue); + expect(port.activations, isEmpty); + expect(port.previews, 0); + expect( + find.byKey(const Key('confirm-recovery-replacement')), + findsNothing, + ); + expect( + tester + .widget( + find.byKey(const Key('select-recovery-backup')), + ) + .onPressed, + isNotNull, + ); + }, + ); + + testWidgets( + 'cancelled final replacement releases fresh ready input and never activates', + (tester) async { + final ready = Candidate(); + final port = Port()..selection = Review(ready); + await Host(port).open(tester); + await select(tester); + await tap(tester, 'revalidate-backup-time'); + // The parent operation remains busy while awaiting this modal choice. + await tester.pump(const Duration(milliseconds: 350)); + await tap(tester, 'cancel-recovery-replacement'); + await tester.pumpAndSettle(); + expect(ready.disposed, isTrue); + expect(port.activations, isEmpty); + }, + ); + + testWidgets( + 'Recovery recurrence confirmation waits for whole validation and separate replacement', + (tester) async { + final ready = Candidate(); + final review = RecurringReview(ready) + ..pendingFullValidation = Completer(); + final port = Port()..selection = review; + await Host(port).open(tester); + await select(tester); + await tap(tester, 'backup-time-issue-rule-1'); + await tap(tester, 'backup-time-recurrence'); + expect(find.byType(BackupRecurringTimeReviewScreen), findsOneWidget); + await recurring.tap(tester, 'review-backup-recurrence'); + expect(port.activations, isEmpty); + expect(review.fullValidations, 0); + await recurring.confirm(tester); + expect(review.choices, hasLength(1)); + expect(review.fullValidations, 1); + expect(port.activations, isEmpty); + expect( + find.byKey(const Key('confirm-recovery-replacement')), + findsNothing, + ); + review.pendingFullValidation!.complete(ready); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + expect( + find.byKey(const Key('confirm-recovery-replacement')), + findsOneWidget, + ); + expect(port.activations, isEmpty); + await tap(tester, 'confirm-recovery-replacement'); + await tester.pumpAndSettle(); + expect(port.activations, [ready]); + expect(ready.disposed, isTrue); + }, + ); + + testWidgets( + 'canceling Recovery recurrence returns to parent with no candidate edit or activation', + (tester) async { + final review = RecurringReview(Candidate()); + final port = Port()..selection = review; + await Host(port).open(tester); + await select(tester); + await tap(tester, 'backup-time-issue-rule-1'); + await tap(tester, 'backup-time-recurrence'); + await recurring.tap(tester, 'cancel-backup-recurrence'); + // The parent stays busy during review, so settle only the route return. + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(BackupTimeReviewScreen), findsOneWidget); + expect(review.fullValidations, 1); + expect(review.choices, isEmpty); + expect(review.disposals, 0); + expect(port.activations, isEmpty); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + expect(review.disposals, greaterThan(0)); + expect(port.activations, isEmpty); + }, + ); + + testWidgets( + 'failed whole validation after recurrence change cannot show replacement confirmation', + (tester) async { + final review = RecurringReview(Candidate()) + ..validationFailure = StateError('private relation failure'); + final port = Port()..selection = review; + await Host(port).open(tester); + await select(tester); + await tap(tester, 'backup-time-issue-rule-1'); + await tap(tester, 'backup-time-recurrence'); + await recurring.tap(tester, 'review-backup-recurrence'); + await recurring.confirm(tester); + expect(review.choices, hasLength(1)); + expect(review.fullValidations, 1); + expect(find.byType(BackupTimeReviewScreen), findsOneWidget); + expect( + find.byKey(const Key('confirm-recovery-replacement')), + findsNothing, + ); + expect(port.activations, isEmpty); + expect(find.textContaining('private relation failure'), findsNothing); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'Recovery recurrence route receives the current owner returned by revalidation', + (tester) async { + final old = RecurringReview(Candidate()); + final next = RecurringReview(Candidate()); + old.replacement = next; + final port = Port()..selection = old; + await Host(port).open(tester); + await select(tester); + await tap(tester, 'revalidate-backup-time'); + await tap(tester, 'backup-time-issue-rule-1'); + await tap(tester, 'backup-time-recurrence'); + await recurring.tap(tester, 'review-backup-recurrence'); + expect(old.drafts, isEmpty); + expect(next.drafts, hasLength(1)); + await recurring.tap(tester, 'cancel-backup-recurrence'); + await tap(tester, 'cancel-backup-time'); + await tester.pumpAndSettle(); + expect(port.activations, isEmpty); + }, + ); + + testWidgets( + 'preclaim cleanup failure retains same owner and retries only cleanup', + (tester) async { + final candidate = Candidate(); + final port = Port() + ..selection = candidate + ..activationFailure = staleAfterCleanup + ..cleanupFailures = 1; + final host = Host(port); + await host.open(tester); + await activate(tester); + expect(candidate.disposed, isTrue); + expect(find.textContaining('The backup was not applied'), findsOneWidget); + expect(find.text('Continue recovery'), findsNothing); + expect(find.text('Stop this recovery'), findsNothing); + expect(find.byKey(const Key('select-recovery-backup')), findsNothing); + await tap(tester, 'retry-recovery-preclaim-cleanup'); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('retry-recovery-preclaim-cleanup')), + findsOneWidget, + ); + expect(find.textContaining('private cleanup'), findsNothing); + await tap(tester, 'retry-recovery-preclaim-cleanup'); + await tester.pumpAndSettle(); + expect(port.cleanupGenerations, [7, 7]); + expect(port.activations, [candidate]); + expect(host.factories, 1); + expect(port.resumes, 0); + expect(port.aborts, 0); + expect(host.completed, 0); + expect(host.exited, 0); + expect(find.byKey(const Key('select-recovery-backup')), findsOneWidget); + }, + ); + + testWidgets( + 'preclaim retry remains single flight while native cleanup is pending', + (tester) async { + final pending = Completer(); + final port = Port() + ..selection = Candidate() + ..activationFailure = staleAfterCleanup + ..pendingCleanup = pending; + await Host(port).open(tester); + await activate(tester); + await tap(tester, 'retry-recovery-preclaim-cleanup'); + expect( + tester + .widget( + find.byKey(const Key('retry-recovery-preclaim-cleanup')), + ) + .onPressed, + isNull, + ); + await tester.tap( + find.byKey(const Key('retry-recovery-preclaim-cleanup')), + ); + await tester.pump(); + expect(port.cleanupGenerations, [7]); + expect(port.activations.length, 1); + pending.complete(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('select-recovery-backup')), findsOneWidget); + }, + ); + + testWidgets( + 'candidate cleanup failure blocks another selection and offers local retry', + (tester) async { + final candidate = Candidate()..releaseFailures = 1; + final port = Port()..selection = candidate; + await Host(port).open(tester); + await select(tester); + await tap(tester, 'cancel-recovery-replacement'); + await tester.pumpAndSettle(); + expect(candidate.disposed, isFalse); + expect( + tester + .widget( + find.byKey(const Key('select-recovery-backup')), + ) + .onPressed, + isNull, + ); + expect(find.textContaining('private file'), findsNothing); + await tap(tester, 'retry-recovery-candidate-cleanup'); + await tester.pumpAndSettle(); + expect(candidate.disposed, isTrue); + expect(port.selections, 1); + expect(port.activations, isEmpty); + expect( + tester + .widget( + find.byKey(const Key('select-recovery-backup')), + ) + .onPressed, + isNotNull, + ); + }, + ); + + testWidgets( + 'late picker result after screen removal is disposed without activation', + (tester) async { + final pending = Completer(); + final port = Port()..pendingSelection = pending; + await Host(port).open(tester); + await select(tester); + await tester.pumpWidget(const MaterialApp(home: Text('Removed'))); + final candidate = Candidate(); + pending.complete(candidate); + await tester.pumpAndSettle(); + expect(candidate.disposed, isTrue); + expect(port.activations, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + + for (final locale in ['ko', 'en']) { + testWidgets( + '$locale preclaim state is usable at narrow width with large text', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final port = Port() + ..selection = Candidate() + ..activationFailure = staleAfterCleanup; + await Host(port).open(tester, locale: locale, scale: 2.5); + await activate(tester, locale: locale); + await tap(tester, 'retry-recovery-preclaim-cleanup'); + await tester.pumpAndSettle(); + expect(port.cleanupGenerations, [7]); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/tool/check_database_schema.dart b/tool/check_database_schema.dart new file mode 100644 index 00000000..eefeba99 --- /dev/null +++ b/tool/check_database_schema.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:on_time_front/core/database/schema_contracts.g.dart'; +import 'database_schema_input.dart'; + +Future main() async { + try { + const path = 'lib/core/database/schema_contracts.schema.json'; + final source = await File(path).readAsString(); + await validateDatabaseSchemaInput( + jsonDecode(source) as Map, + (path) => File(path).readAsBytes(), + ); + if (databaseSchemaContractsDigest != + sha256.convert(utf8.encode(source)).toString() || + databaseSchemaContractsJson != source) { + throw StateError( + 'Generated schema contracts are stale; run build_runner', + ); + } + stdout.writeln( + 'Historical schema contracts, DDL, lineage and generated digest agree.', + ); + } catch (error) { + stderr.writeln('Database schema gate failed: $error'); + exitCode = 1; + } +} diff --git a/tool/database_schema_builder.dart b/tool/database_schema_builder.dart new file mode 100644 index 00000000..74188538 --- /dev/null +++ b/tool/database_schema_builder.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'package:build/build.dart'; +import 'package:crypto/crypto.dart'; +import 'database_schema_input.dart'; + +Builder databaseSchemaContracts(BuilderOptions options) => _SchemaBuilder(); + +class _SchemaBuilder implements Builder { + @override + Map> get buildExtensions => const { + '.schema.json': ['.g.dart'], + }; + @override + Future build(BuildStep step) async { + final source = await step.readAsString(step.inputId); + await validateDatabaseSchemaInput( + jsonDecode(source) as Map, + (path) => step.readAsBytes(AssetId(step.inputId.package, path)), + ); + final encoded = jsonEncode(source).replaceAll(r'$', r'\$'); + final output = AssetId( + step.inputId.package, + step.inputId.path.replaceFirst(RegExp(r'\.schema\.json$'), '.g.dart'), + ); + await step.writeAsString( + output, + '// GENERATED CODE - DO NOT MODIFY BY HAND\n' + '// Created only by database_schema_contracts build_runner builder.\n' + 'const databaseSchemaContractsDigest = "${sha256.convert(utf8.encode(source))}";\n' + 'const databaseSchemaContractsJson = $encoded;\n', + ); + } +} diff --git a/tool/database_schema_input.dart b/tool/database_schema_input.dart new file mode 100644 index 00000000..7524fb4f --- /dev/null +++ b/tool/database_schema_input.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:sqlite3/sqlite3.dart'; + +/// Tool-only: execute the frozen DDL in isolated memory, then compare every +/// schema object and PRAGMA descriptor. No product model or database is used. +Future validateDatabaseSchemaInput( + Map data, + Future> Function(String path) read, +) async { + if (data['format'] != 1 || data['current'] is! int) { + throw StateError('Invalid schema format/current'); + } + final snapshots = data['snapshots']; + if (snapshots is! List || snapshots.isEmpty) { + throw StateError('Missing snapshots'); + } + final versions = {}; + const equality = DeepCollectionEquality(); + for (final entry in snapshots) { + if (entry is! Map) throw StateError('Invalid snapshot'); + final version = entry['version']; + if (version is! int || + version < 1 || + !versions.add(version) || + entry['sourceCommit'] is! String || + !RegExp(r'^[a-f0-9]{40}$').hasMatch(entry['sourceCommit'] as String) || + entry['engine'] is! String || + (entry['engine'] as String).isEmpty) { + throw StateError('Invalid schema provenance/version'); + } + final path = entry['ddlPath']; + if (path != 'test/fixtures/database/schema_v$version.sql') { + throw StateError('Invalid schema fixture path'); + } + final bytes = await read(path as String); + if (sha256.convert(bytes).toString() != entry['ddlSha256']) { + throw StateError('Historical schema $version digest mismatch'); + } + final raw = sqlite3.openInMemory(); + try { + raw.execute(utf8.decode(bytes)); + if (raw.userVersion != version) throw StateError('DDL version mismatch'); + final objects = raw.select( + "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT GLOB 'sqlite_*' AND sql IS NOT NULL ORDER BY type,name", + ); + final tables = {}; + for (final object in objects.where((o) => o['type'] == 'table')) { + final name = object['name'] as String; + final quoted = '"${name.replaceAll('"', '""')}"'; + final indexes = >[]; + for (final index in raw.select('PRAGMA index_list($quoted)')) { + final iq = '"${(index['name'] as String).replaceAll('"', '""')}"'; + indexes.add({ + 'unique': index['unique'], + 'origin': index['origin'], + 'partial': index['partial'], + 'columns': raw + .select('PRAGMA index_xinfo($iq)') + .map( + (r) => { + for (final k in ['seqno', 'name', 'desc', 'coll', 'key']) + k: r[k], + }, + ) + .toList(), + }); + } + tables[name] = { + 'columns': raw + .select('PRAGMA table_xinfo($quoted)') + .map( + (r) => { + for (final k in [ + 'name', + 'type', + 'notnull', + 'dflt_value', + 'pk', + 'hidden', + ]) + k: r[k], + }, + ) + .toList(), + 'foreignKeys': raw + .select('PRAGMA foreign_key_list($quoted)') + .map((r) => Map.from(r)) + .toList(), + 'indexes': indexes, + }; + } + if (!equality.equals( + objects.map((r) => Map.from(r)).toList(), + entry['objects'], + ) || + !equality.equals(tables, entry['tables'])) { + throw StateError('Snapshot $version does not describe its actual DDL'); + } + } finally { + raw.dispose(); + } + } + Set checkedVersions(Object? value) { + if (value is! List || value.isEmpty || value.any((v) => v is! int)) { + throw StateError('Invalid supported versions'); + } + final set = value.cast().toSet(); + if (set.length != value.length || + !set.contains(data['current']) || + !set.every(versions.contains)) { + throw StateError('Missing/duplicate supported schema'); + } + return set; + } + + final legacy = checkedVersions(data['legacySupported']); + final pair = checkedVersions(data['pairSupported']); + if (legacy.length != versions.length || + pair.any((v) => v < 4) || + versions.any((v) => v > (data['current'] as int))) { + throw StateError('Invalid supported schema lineage'); + } +} diff --git a/tool/qa/android_sqlite_control.kt b/tool/qa/android_sqlite_control.kt new file mode 100644 index 00000000..4d7ac8e3 --- /dev/null +++ b/tool/qa/android_sqlite_control.kt @@ -0,0 +1,38 @@ +package dev.ontime.qa.ontimed04probe + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "ontime.d04.qa") + .setMethodCallHandler { call, result -> + if (call.method != "ordinarySQLiteRejects") { result.notImplemented(); return@setMethodCallHandler } + val path = call.arguments as? String + if (path == null || !path.startsWith(filesDir.parentFile!!.absolutePath)) { + result.error("scope", "Only own QA sandbox", null); return@setMethodCallHandler + } + try { + val memory = SQLiteDatabase.create(null) + val isOrdinary = memory.rawQuery("PRAGMA cipher_version", null).use { !it.moveToFirst() } + val isWorking = memory.rawQuery("SELECT sqlite_version()", null).use { it.moveToFirst() && it.getString(0).isNotBlank() } + memory.close() + var rejected = false + try { + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("SELECT note FROM users", null).use { it.moveToFirst() } + } + } catch (error: SQLiteException) { + rejected = true + } + result.success(isOrdinary && isWorking && rejected) + } catch (error: Exception) { + result.error("probe", error.javaClass.simpleName, null) + } + } + } +} diff --git a/tool/qa/backup_native_probe.dart b/tool/qa/backup_native_probe.dart new file mode 100644 index 00000000..9d09c544 --- /dev/null +++ b/tool/qa/backup_native_probe.dart @@ -0,0 +1,399 @@ +// Structured synthetic QA evidence is intentionally emitted to captured logs. +// ignore_for_file: avoid_print +// Separate D05 QA bundle only: synthetic schedules, no user installation/data. +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_ingestion_store.dart'; +import 'package:on_time_front/core/backup/backup_limits.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/backup/backup_validated_ingestion.dart'; +import 'package:on_time_front/core/backup/restore_staging.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_registration_cleanup.dart'; +import 'package:on_time_front/domain/entities/backup_processing.dart'; +import 'package:on_time_front/domain/ports/backup_file_export_port.dart'; +import 'package:on_time_front/domain/ports/backup_file_import_port.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; + +const _password = 'D05 synthetic cross-platform backup password'; +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const MaterialApp( + home: Scaffold(body: Center(child: Text('D05 synthetic backup probe'))), + ), + ); + final documents = await getApplicationDocumentsDirectory(); + if (await File('${documents.path}/d05-cross-only').exists()) { + await _crossOnly(documents); + return; + } + final result = File('${documents.path}/d05-backup-result.json'); + final owned = Directory('${documents.path}/d05-owned'); + final rows = >[]; + var phase = 'initializing'; + final timer = Stopwatch()..start(); + try { + await owned.create(recursive: true); + final crypto = BackupCrypto(); + final baselineRss = ProcessInfo.currentRss; + phase = 'fixture-crypto'; + var size = 0; + await for (final part in _fixture()) { + size += part.length; + } + final fixture = File('${owned.path}/fixture.cipher'); + final sink = fixture.openWrite(); + await sink.addStream( + crypto.encryptStream( + plaintext: _fixture(), + plaintextLength: size, + password: _password, + ), + ); + await sink.flush(); + await sink.close(); + phase = 'common-ingestion'; + final budget = BackupBudget(); + final ingestionStart = timer.elapsedMilliseconds; + final data = await BackupValidatedIngestion.decrypt( + ciphertext: fixture.openRead(), + password: _password, + crypto: crypto, + budget: budget, + createStore: (b) => BackupIngestionStore.create( + b, + root: Directory('${owned.path}/ingestion'), + ), + ); + rows.add({ + 'phase': phase, + 'ms': timer.elapsedMilliseconds - ingestionStart, + 'work': budget.work, + 'rss': ProcessInfo.currentRss, + }); + phase = 'materialize'; + final materializeStart = timer.elapsedMilliseconds; + final active = await RestoreStaging.create( + root: Directory('${owned.path}/active'), + ); + await data.materialize(active.database, pendingCleanup: false); + await data.validateReadBack(active.database, pendingCleanup: false); + rows.add({ + 'phase': phase, + 'ms': timer.elapsedMilliseconds - materializeStart, + 'work': budget.work, + 'rss': ProcessInfo.currentRss, + }); + await data.release(); + final output = File( + '${documents.path}/d05-export-${Platform.operatingSystem}.ontimebackup', + ); + final owner = BackupProcessingOwner(); + BackupBudget? exportBudget; + final service = BackupService( + active.database, + _Metadata(), + _NoAlarms(), + crypto: crypto, + processingOwner: owner, + operationGate: LocalDataOperationGate(), + runtimeIdentity: RestoreRuntimeIdentity(), + cleanupPlatform: () async {}, + exportPort: _OwnedOutput(output), + importPort: _OwnedInput(output), + stagingFactory: () => + RestoreStaging.create(root: Directory('${owned.path}/candidate')), + ingestionFactory: (b) { + exportBudget = b; + return BackupIngestionStore.create( + b, + root: Directory('${owned.path}/service-ingestion'), + ); + }, + ); + phase = 'service-export'; + final exportStart = timer.elapsedMilliseconds; + if (await service.exportToUserSelectedFile(_password) != + BackupExportResult.saved) { + throw StateError('save'); + } + rows.add({ + 'phase': phase, + 'ms': timer.elapsedMilliseconds - exportStart, + 'work': exportBudget!.work, + 'plaintextBytes': exportBudget!.plainBytes, + 'cipherBytes': await output.length(), + 'rss': ProcessInfo.currentRss, + }); + phase = 'service-preview-apply'; + final restoreStart = timer.elapsedMilliseconds; + final candidate = await service.selectAndPreviewRestore(_password); + if (candidate?.preview.scheduleCount != 10000) throw StateError('count'); + await service.applyRestore(candidate!); + if (owner.active != null) throw StateError('lease'); + final count = + (await active.database + .customSelect('SELECT count(*) AS n FROM schedules') + .getSingle()) + .read('n'); + if (count != 10000) throw StateError('restored count'); + final user = await active.database + .select(active.database.users) + .getSingle(); + if (user.restoreCleanupPending) throw StateError('pending'); + rows.add({ + 'phase': phase, + 'ms': timer.elapsedMilliseconds - restoreStart, + 'work': exportBudget!.work, + 'rss': ProcessInfo.currentRss, + 'count': count, + }); + final cross = File('${documents.path}/d05-cross-input.ontimebackup'); + if (await cross.exists()) { + phase = 'cross-platform-input'; + final crossStart = timer.elapsedMilliseconds; + final crossData = await BackupValidatedIngestion.decrypt( + ciphertext: cross.openRead(), + password: _password, + crypto: crypto, + budget: BackupBudget(), + createStore: (b) => BackupIngestionStore.create( + b, + root: Directory('${owned.path}/cross'), + ), + ); + if (crossData.preview.scheduleCount != 10000) { + throw StateError('cross count'); + } + final crossStage = await RestoreStaging.create( + root: Directory('${owned.path}/cross-candidate'), + ); + await crossData.materialize(crossStage.database, pendingCleanup: false); + await crossData.validateReadBack( + crossStage.database, + pendingCleanup: false, + ); + rows.add({ + 'phase': phase, + 'ms': timer.elapsedMilliseconds - crossStart, + 'sourcePlatform': crossData.preview.sourcePlatform, + 'count': crossData.preview.scheduleCount, + }); + await crossData.release(); + await crossStage.release(); + } + final cipher = + (await active.database + .customSelect('PRAGMA cipher_version') + .getSingle()) + .data + .values + .single; + await active.release(); + await fixture.delete(); + final remainder = await owned + .list(recursive: true) + .where((entry) => entry is File) + .toList(); + if (remainder.isNotEmpty) throw StateError('temporary residue'); + final report = { + 'passed': true, + 'platform': Platform.operatingSystem, + 'osVersion': Platform.operatingSystemVersion, + 'cipher': cipher, + 'elapsedMs': timer.elapsedMilliseconds, + 'baselineRss': baselineRss, + 'finalRss': ProcessInfo.currentRss, + 'processPeakRss': ProcessInfo.maxRss, + 'phases': rows, + 'scope': + 'Actual mobile SQLCipher, sodium and bounded normal BackupService export/preview/apply. QA owned-file ports and no-alarm cleanup; not actual system picker/provider or user runtime alarms. Peak includes fixture producer and Flutter rendering.', + }; + await result.writeAsString(jsonEncode(report)); + print('D05_BACKUP_MOBILE ${jsonEncode(report)}'); + } catch (error, stack) { + final report = { + 'passed': false, + 'phase': phase, + 'error': error.toString(), + 'stack': stack.toString(), + 'phases': rows, + }; + await result.writeAsString(jsonEncode(report)); + print('D05_BACKUP_MOBILE ${jsonEncode(report)}'); + } +} + +Stream> _fixture() async* { + yield utf8.encode( + jsonEncode({ + 'formatVersion': 1, + 'cutoff': '2026-09-24T00:00:00Z', + 'sourceAppVersion': 'D05 synthetic', + 'sourcePlatform': 'fixture', + 'dataRevision': 1, + 'profile': { + 'spareTimeMinutes': 0, + 'note': 'synthetic', + 'isOnboardingCompleted': true, + 'eligibleOutcomeCount': 0, + 'onTimeOutcomeCount': 0, + }, + 'preferences': { + 'alarmsEnabled': false, + 'alarmOffsetMinutes': 0, + 'detailedNotificationContent': false, + }, + 'defaultPreparation': [ + {'id': 'step', 'name': 'Prepare', 'minutes': 0, 'nextId': null}, + ], + 'schedulePreparations': {}, + 'templates': [], + }).replaceFirst(RegExp(r'}$'), ',"schedules":['), + ); + for (var i = 0; i < 10000; i++) { + yield utf8.encode( + '${i == 0 ? '' : ','}${jsonEncode({ + 'id': 'schedule-$i', + 'place': {'id': 'place-$i', 'name': 'Fixture $i'}, + 'name': 'Meeting', + 'civilTime': '2026-09-01T09:00:00Z', + 'timeZoneId': 'Asia/Seoul', + 'occurrenceOffsetSeconds': 32400, + 'moveTimeMinutes': 0, + 'spareTimeMinutes': 0, + 'isChanged': false, + 'note': '', + 'latenessTime': -1, + 'doneStatus': 'notEnded', + 'preparationTemplateDeleted': false, + 'scoreContributionRecorded': false, + })}', + ); + } + yield utf8.encode(']}'); +} + +class _Metadata implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: 'D05 QA', buildNumber: '1'); +} + +class _NoAlarms implements CancelAllAlarmsUseCase { + @override + AlarmOperationCoordinator get operations => + throw UnsupportedError('No alarm registrations in this synthetic QA'); + @override + Future withCleanupOwner( + Future Function(AlarmRegistrationCleanup) action, + ) => throw UnsupportedError('No alarm registrations in this synthetic QA'); + @override + Future call() async {} + @override + Future forDataReplacement() async {} +} + +class _OwnedOutput implements BackupFileExportPort { + _OwnedOutput(this.file); + final File file; + @override + Future exportStream({ + required Stream> encrypted, + required String suggestedName, + BackupProcessingLease? lease, + }) async { + final sink = file.openWrite(); + try { + await sink.addStream(encrypted); + await sink.flush(); + } finally { + await sink.close(); + } + return BackupFileExportReceipt.saved; + } +} + +class _OwnedInput implements BackupFileImportPort { + _OwnedInput(this.file); + final File file; + @override + Future select({BackupProcessingLease? lease}) async => + _OwnedSource(file, await file.length()); +} + +class _OwnedSource implements BackupImportSource { + _OwnedSource(this.file, this.declaredLength); + final File file; + @override + final int declaredLength; + @override + Stream openRead() => file.openRead().map(Uint8List.fromList); + @override + Future close() async {} +} + +Future _crossOnly(Directory documents) async { + final timer = Stopwatch()..start(); + final result = File('${documents.path}/d05-cross-result.json'); + final root = Directory('${documents.path}/d05-cross-owned'); + try { + final input = File('${documents.path}/d05-cross-input.ontimebackup'); + final budget = BackupBudget(); + final data = await BackupValidatedIngestion.decrypt( + ciphertext: input.openRead(), + password: _password, + crypto: BackupCrypto(), + budget: budget, + createStore: (b) => BackupIngestionStore.create(b, root: root), + ); + final stage = await RestoreStaging.create(root: root); + await data.materialize(stage.database, pendingCleanup: false); + await data.validateReadBack(stage.database, pendingCleanup: false); + final count = + (await stage.database + .customSelect('SELECT count(*) AS n FROM schedules') + .getSingle()) + .read('n'); + if (count != 10000 || + data.preview.sourcePlatform == Platform.operatingSystem) { + throw StateError('wrong cross fixture'); + } + final source = data.preview.sourcePlatform; + await data.release(); + await stage.release(); + if (await root.list().any((entry) => entry is File)) { + throw StateError('cross residue'); + } + final report = { + 'passed': true, + 'targetPlatform': Platform.operatingSystem, + 'sourcePlatform': source, + 'count': count, + 'inputBytes': await input.length(), + 'work': budget.work, + 'elapsedMs': timer.elapsedMilliseconds, + 'processPeakRss': ProcessInfo.maxRss, + 'scope': + 'Actual foreign mobile ciphertext authenticated and fully validated/materialized/read back with SQLCipher; QA-owned input file, not system picker.', + }; + await result.writeAsString(jsonEncode(report)); + print('D05_CROSS_MOBILE ${jsonEncode(report)}'); + } catch (error, stack) { + final report = { + 'passed': false, + 'error': error.toString(), + 'stack': stack.toString(), + }; + await result.writeAsString(jsonEncode(report)); + print('D05_CROSS_MOBILE ${jsonEncode(report)}'); + } +} diff --git a/tool/qa/capture_damaged_store_recovery.dart b/tool/qa/capture_damaged_store_recovery.dart new file mode 100644 index 00000000..37dd9fbb --- /dev/null +++ b/tool/qa/capture_damaged_store_recovery.dart @@ -0,0 +1,130 @@ +// Synthetic real-widget raster evidence; no provider/plugin/mobile runtime proof. +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/ports/recovery_restore_port.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/startup/screens/recovery_restore_screen.dart'; + +class _Input extends BackupRestoreInput { + @override + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: DateTime.utc(2026, 9, 24), + sourceAppVersion: '1.0.0', + sourcePlatform: 'android', + scheduleCount: 12, + templateCount: 3, + defaultPreparationStepCount: 4, + ); +} + +class _Port extends Fake implements RecoveryRestorePort { + @override + Future preview(String password) async => _Input(); +} + +void main() { + testWidgets('KO EN 200 percent damaged recovery states', (tester) async { + final output = Directory(Platform.environment['D02_CAPTURE_DIR']!); + await tester.runAsync(() => output.create(recursive: true)); + final font = FontLoader('Pretendard') + ..addFont(rootBundle.load('assets/fonts/Pretendard-Regular.ttf')); + await tester.runAsync(font.load); + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final language in ['ko', 'en']) { + final receipts = { + 'select': null, + 'unknown': const BackupRestoreReceipt.uncertain(generation: 1), + 'unapplied': BackupRestoreReceipt( + disposition: BackupCommitDisposition.notCommitted, + generation: 1, + followUpPending: true, + ), + 'restart': const BackupRestoreReceipt.recovery( + generation: 1, + phase: RecoveryFollowUp.awaitingNewProcess, + ), + 'cleanup': const BackupRestoreReceipt.recovery( + generation: 1, + phase: RecoveryFollowUp.cleanupPending, + ), + }; + for (final entry in receipts.entries) { + final key = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: MaterialApp( + key: ValueKey('$language-${entry.key}'), + debugShowCheckedModeBanner: false, + theme: themeData, + locale: Locale(language), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: RecoveryRestoreScreen( + port: () async => _Port(), + initialReceipt: entry.value, + onCancelled: () {}, + onCompleted: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + Future capture(String name) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.takeException(), isNull); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + await File( + '${output.path}/$name-$language.png', + ).writeAsBytes(bytes!.buffer.asUint8List(), flush: true); + image.dispose(); + }); + } + + await capture(entry.key); + if (entry.key == 'select') { + await tester.tap( + find.text(language == 'ko' ? '백업 파일 선택' : 'Choose backup file'), + ); + await capture('password'); + await tester.enterText( + find.byType(TextField), + 'synthetic backup password', + ); + await tester.tap(find.text(language == 'ko' ? '계속' : 'Continue')); + await capture('preview'); + expect( + find.textContaining( + language == 'ko' ? '알 수 없습니다' : 'count is unknown', + ), + findsOneWidget, + ); + await tester.tap(find.text(language == 'ko' ? '취소' : 'Cancel')); + await tester.pumpAndSettle(); + } + } + } + }); +} diff --git a/tool/qa/capture_default_preferences.dart b/tool/qa/capture_default_preferences.dart new file mode 100644 index 00000000..b1c5de1b --- /dev/null +++ b/tool/qa/capture_default_preferences.dart @@ -0,0 +1,122 @@ +// Synthetic receipt rendering, not mobile DB or notification-delivery proof. +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/default_preferences.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; +import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import '../../test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart' + show TestPreferencesWorkflow; + +void main() { + testWidgets('KO EN 200 percent default preference receipt states', ( + tester, + ) async { + final output = Directory('/tmp/c01-defaults-ui'); + await tester.runAsync(() => output.create(recursive: true)); + final font = FontLoader('Pretendard') + ..addFont(rootBundle.load('assets/fonts/Pretendard-Regular.ttf')); + await tester.runAsync(font.load); + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await tester.runAsync(icons.load); + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final language in ['ko', 'en']) { + for (final state in [ + 'load-failed', + 'save-failed', + 'conflict', + 'reload-pending', + 'delivery-pending', + 'both-pending', + 'authority-pending', + 'expired', + ]) { + await getIt.reset(); + final workflow = TestPreferencesWorkflow(); + if (state == 'load-failed') { + workflow.readHandler = () async => throw StateError('synthetic'); + } + workflow.saveHandler = (_) async { + if (state == 'save-failed') { + throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.failed, + ); + } + if (state == 'conflict') { + throw const DefaultPreferencesRejected( + DefaultPreferencesFailure.conflict, + ); + } + return workflow + .receipt( + reload: state == 'reload-pending' || state == 'both-pending', + delivery: + state == 'delivery-pending' || state == 'both-pending', + ) + .withFollowUp( + reloadPending: + state == 'reload-pending' || state == 'both-pending', + deliveryPending: + state == 'delivery-pending' || state == 'both-pending', + authorityPending: state == 'authority-pending', + superseded: state == 'expired', + ); + }; + getIt.registerFactory( + () => DefaultPreparationSpareTimeFormBloc(workflow), + ); + getIt.registerFactory(PreparationFormBloc.new); + final key = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData, + locale: Locale(language), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: const PreparationSpareTimeEditScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + if (state != 'load-failed') { + await tester.tap(find.byType(TextButton).first); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await File( + '${output.path}/$state-$language.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + } + } + await getIt.reset(); + }); +} diff --git a/tool/qa/capture_restore_cleanup.dart b/tool/qa/capture_restore_cleanup.dart new file mode 100644 index 00000000..1c9e9c31 --- /dev/null +++ b/tool/qa/capture_restore_cleanup.dart @@ -0,0 +1,199 @@ +// Synthetic widget raster evidence. No mobile picker, OS delivery, or real data. +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/backup_operation.dart'; +import 'package:on_time_front/domain/ports/local_data_ports.dart'; +import 'package:on_time_front/domain/use-cases/local_data_workflows.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +class _Input extends BackupRestoreInput { + _Input({this.failCleanup = false}); + final bool failCleanup; + @override + Future dispose() async { + if (failCleanup) { + throw RestoreStagingCleanupFailure( + cleanupError: StateError('synthetic unlink failure'), + ); + } + } + + @override + BackupRestorePreview get preview => BackupRestorePreview( + cutoff: DateTime.utc(2026, 9, 24), + sourceAppVersion: '1.0', + sourcePlatform: 'android', + scheduleCount: 2, + templateCount: 1, + defaultPreparationStepCount: 3, + ); +} + +class _Backup implements BackupOperationsPort { + @override + int generation = 0; + bool freshnessFails = true; + bool failCleanup = false; + int applications = 0; + @override + Future freshness() async { + if (freshnessFails) throw StateError('synthetic'); + return const BackupFreshnessStatus( + freshness: BackupFreshness.neverExported, + ); + } + + @override + Future export(String password) async => + BackupExportResult.saved; + @override + Future preview(String password) async => + _Input(failCleanup: failCleanup); + @override + Future apply(BackupRestoreInput input) async { + applications++; + return ++generation; + } +} + +class _Delivery implements RestoreDeliveryPort { + @override + Future reconcile() async => false; +} + +void main() { + testWidgets('KO EN 200 percent data workflow states', (tester) async { + final output = Directory(Platform.environment['A09_CAPTURE_DIR']!); + await tester.runAsync(() => output.create(recursive: true)); + final font = FontLoader('Pretendard') + ..addFont(rootBundle.load('assets/fonts/Pretendard-Regular.ttf')); + await tester.runAsync(font.load); + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await tester.runAsync(icons.load); + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final language in ['ko', 'en']) { + final port = _Backup(); + final workflow = BackupWorkflow(port, _Delivery()); + final key = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: MaterialApp( + key: ValueKey(language), + debugShowCheckedModeBanner: false, + theme: themeData, + locale: Locale(language), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: MyDataScreen(workflow: workflow), + ), + ), + ); + Future capture(String name) async { + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await File( + '${output.path}/$name-$language.png', + ).writeAsBytes(bytes!.buffer.asUint8List(), flush: true); + image.dispose(); + }); + } + + await capture('freshness-failed'); + port.freshnessFails = false; + await tester.tap(find.text(language == 'ko' ? '다시 시도' : 'Try again')); + await tester.pumpAndSettle(); + final restore = find.text( + language == 'ko' ? '백업에서 복원' : 'Restore from backup', + ); + await tester.ensureVisible(restore); + await tester.tap(restore); + await tester.pumpAndSettle(); + await capture('password'); + await tester.enterText( + find.byType(TextField), + 'synthetic backup password', + ); + await tester.tap(find.text(language == 'ko' ? '계속' : 'Continue')); + await tester.pumpAndSettle(); + await capture('preview'); + await tester.tap(find.text(language == 'ko' ? '복원' : 'Restore')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, 800)); + await capture('restore-pending'); + expect( + find.text(language == 'ko' ? '남은 처리 다시 시도' : 'Retry remaining work'), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + port.failCleanup = true; + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData, + locale: Locale(language), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: MyDataScreen(workflow: workflow), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(restore); + await tester.tap(restore); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField), + 'synthetic backup password', + ); + await tester.tap(find.text(language == 'ko' ? '계속' : 'Continue')); + await tester.pumpAndSettle(); + await tester.tap(find.text(language == 'ko' ? '취소' : 'Cancel')); + await tester.pumpAndSettle(); + expect(port.applications, 1); + expect( + find.textContaining( + language == 'ko' ? '임시 파일 정리' : 'temporary file cleanup', + ), + findsOneWidget, + ); + expect( + find.text(language == 'ko' ? '남은 처리 다시 시도' : 'Retry remaining work'), + findsNothing, + ); + await capture('staging-cleanup-failed'); + } + }); +} diff --git a/tool/qa/emit_database_schema.dart b/tool/qa/emit_database_schema.dart new file mode 100644 index 00000000..f3497e47 --- /dev/null +++ b/tool/qa/emit_database_schema.dart @@ -0,0 +1,32 @@ +// Run with flutter test. Output is an explicit external baseline, never copied +// over historical contracts by this command or by the application generator. +import 'dart:convert'; +import 'dart:io'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; + +void main() { + test('emit current schema to the explicitly requested baseline path', () async { + final output = Platform.environment['ONTIME_SCHEMA_OUTPUT']; + if (output == null) throw StateError('ONTIME_SCHEMA_OUTPUT is required'); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + try { + await db.customSelect('SELECT 1').get(); + final rows = await db + .customSelect( + "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT GLOB 'sqlite_*' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END,name", + ) + .get(); + await File(output).writeAsString( + '${rows.map((r) => r.read('sql')).join(';\n')};\nPRAGMA user_version=${db.schemaVersion};\n', + ); + final engine = await db + .customSelect('SELECT sqlite_version() AS version') + .getSingle(); + await File('$output.engine.json').writeAsString(jsonEncode(engine.data)); + } finally { + await db.close(); + } + }); +} diff --git a/tool/qa/migration_crash_probe.dart b/tool/qa/migration_crash_probe.dart new file mode 100644 index 00000000..1ce634d4 --- /dev/null +++ b/tool/qa/migration_crash_probe.dart @@ -0,0 +1,167 @@ +// Synthetic host process-death probe. Never selects an installed app database. +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:sqlite3/open.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +const syntheticKey = + '0707070707070707070707070707070707070707070707070707070707070707'; + +class _CheckpointDatabase extends AppDatabase { + _CheckpointDatabase(super.executor, this.stop, this.point) + : super.forTesting(); + final Future Function() stop; + final String point; + bool first = false; + @override + Future customStatement(String statement, [List? args]) async { + if (point == 'before-version' && + statement.startsWith('PRAGMA user_version =')) { + await stop(); + } + await super.customStatement(statement, args); + if (point == 'after-version' && + statement.startsWith('PRAGMA user_version =')) { + await stop(); + } + if (!first && + (statement.startsWith('CREATE TABLE') || + statement.startsWith('ALTER TABLE'))) { + first = true; + if (point == 'after-first-ddl') await stop(); + } + } + + @override + MigrationStrategy get migration { + final base = super.migration; + return MigrationStrategy( + onCreate: base.onCreate, + onUpgrade: base.onUpgrade, + beforeOpen: (details) async { + if (details.hadUpgrade && point == 'after-commit') await stop(); + await base.beforeOpen?.call(details); + }, + ); + } +} + +void main() { + test( + 'actual process death leaves complete encrypted historical or current store', + () async { + final directory = Platform.environment['D04_PROBE_DIR']; + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + final point = Platform.environment['D04_PROBE_POINT']; + if (directory == null || library == null || point == null) { + throw StateError('Explicit synthetic probe environment required'); + } + open.overrideForAll(() => DynamicLibrary.open(library)); + final file = File('$directory/store.sqlite'); + if (Platform.environment['D04_PROBE_MODE'] == 'cut') { + final original = sqlite.sqlite3.open(file.path); + guardEncryptedDatabase( + original, + syntheticKey, + role: DatabaseOpenRole.ownedCreation, + allowCreation: true, + ); + original.execute( + File('test/fixtures/database/schema_v1.sql').readAsStringSync(), + ); + original.execute( + File('test/fixtures/database/populated_local.sql').readAsStringSync(), + ); + final image = >>{}; + for (final row in original.select( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*'", + )) { + final name = row['name'] as String; + image[name] = original + .select('SELECT * FROM "$name" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(); + } + File( + '$directory/original-image.json', + ).writeAsStringSync(jsonEncode(image), flush: true); + original.dispose(); + final db = _CheckpointDatabase( + NativeDatabase( + file, + setup: (raw) => guardEncryptedDatabase( + raw, + syntheticKey, + role: DatabaseOpenRole.legacyStartup, + ), + ), + () async { + File('$directory/cut-ready').writeAsStringSync(point, flush: true); + await Completer().future; + }, + point, + ); + await db.customSelect('SELECT 1').get(); + throw StateError('Checkpoint was not reached'); + } + final raw = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + try { + guardEncryptedDatabase( + raw, + syntheticKey, + role: DatabaseOpenRole.legacyStartup, + ); + expect(raw.userVersion, point == 'after-commit' ? 4 : 1); + final image = + jsonDecode( + File('$directory/original-image.json').readAsStringSync(), + ) + as Map; + for (final entry in image.entries) { + final rows = (entry.value as List).cast>(); + final projection = rows.first.keys.map((k) => '"$k"').join(','); + expect( + raw + .select('SELECT $projection FROM "${entry.key}" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(), + rows, + ); + } + File('$directory/verified').writeAsStringSync( + jsonEncode({ + 'sqliteVersion': raw + .select('SELECT sqlite_version()') + .single + .values + .single, + 'cipherVersion': raw + .select('PRAGMA cipher_version') + .single + .values + .single, + 'recoverTestPid': pid, + 'schemaVersion': raw.userVersion, + 'tablesWithOriginalRows': image.length, + 'actualCipher': true, + 'mobileBundleVerified': false, + 'powerLossVerified': false, + }), + flush: true, + ); + } finally { + raw.dispose(); + } + }, + ); +} diff --git a/tool/qa/migration_native_probe.dart b/tool/qa/migration_native_probe.dart new file mode 100644 index 00000000..ab9e21b7 --- /dev/null +++ b/tool/qa/migration_native_probe.dart @@ -0,0 +1,217 @@ +// Installed only in the separate D04 QA bundle. Uses synthetic data/key slots. +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/core/database/schema_contract.dart'; +import 'package:on_time_front/core/database/schema_contracts.g.dart'; +import 'package:on_time_front/core/database/sqlcipher_loader.dart'; +import 'package:sqlite3/open.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const MaterialApp( + home: Scaffold( + body: Center(child: Text('D04 synthetic migration probe')), + ), + ), + ); + final documents = await getApplicationDocumentsDirectory(); + final result = File('${documents.path}/d04-result.json'); + final rows = >[]; + var phase = 'initializing'; + final ownedSlots = []; + const storage = FlutterSecureStorage(); + const options = IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ); + try { + configureSqlCipherLoader(); + final session = DateTime.now().microsecondsSinceEpoch; + final directory = Directory('${documents.path}/synthetic-$session'); + await directory.create(); + for (final version in DatabaseSchemaContract.legacySupported) { + phase = 'schema-$version'; + final key = List.generate(32, (_) => Random.secure().nextInt(256)); + final hex = key.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final slot = 'd04-synthetic-$session-v$version'; + ownedSlots.add(slot); + await storage.write(key: slot, value: hex, iOptions: options); + if (await storage.read(key: slot, iOptions: options) != hex) { + throw StateError('QA key readback failed'); + } + final file = File('${directory.path}/v$version.sqlite'); + final original = sqlite.sqlite3.open(file.path); + guardEncryptedDatabase( + original, + hex, + role: DatabaseOpenRole.ownedCreation, + allowCreation: true, + ); + final cipher = + original.select('PRAGMA cipher_version').single.values.single + as String; + final engine = + original.select('SELECT sqlite_version()').single.values.single + as String; + original.execute( + await rootBundle.loadString('fixtures/schema_v$version.sql'), + ); + original.execute( + await rootBundle.loadString('fixtures/populated_local.sql'), + ); + if (version >= 2) { + original.execute( + await rootBundle.loadString('fixtures/populated_recurring.sql'), + ); + } + final image = >>{}; + for (final row in original.select( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*'", + )) { + final name = row['name'] as String; + image[name] = original + .select('SELECT * FROM "$name" ORDER BY rowid') + .map((r) => Map.from(r)) + .toList(); + } + original.dispose(); + for (var reopening = 0; reopening < 2; reopening++) { + final db = AppDatabase.forTesting( + NativeDatabase.createInBackground( + file, + isolateSetup: configureSqlCipherLoader, + setup: (raw) => guardEncryptedDatabase( + raw, + hex, + role: DatabaseOpenRole.legacyStartup, + ), + ), + ); + try { + final current = + (await db.customSelect('PRAGMA user_version').getSingle()) + .read('user_version'); + if (current != DatabaseSchemaContract.current) { + throw StateError('Schema version mismatch'); + } + for (final entry in image.entries) { + final fields = entry.value.first.keys.map((k) => '"$k"').join(','); + final actual = + (await db + .customSelect( + 'SELECT $fields FROM "${entry.key}" ORDER BY rowid', + ) + .get()) + .map((r) => r.data) + .toList(); + if (!DatabaseSchemaContract.equality.equals(actual, entry.value)) { + throw StateError('Historical row image changed'); + } + } + } finally { + await db.close(); + } + } + for (final attemptedKey in [null, 'incorrect synthetic key']) { + final raw = sqlite.sqlite3.open( + file.path, + mode: sqlite.OpenMode.readOnly, + ); + var rejected = false; + try { + if (attemptedKey != null) raw.execute("PRAGMA key = '$attemptedKey'"); + raw.select('SELECT note FROM users'); + } on sqlite.SqliteException { + rejected = true; + } finally { + raw.dispose(); + } + if (!rejected) throw StateError('Missing/wrong key accepted'); + } + final path = file.path; + final plainRejected = Platform.isAndroid + ? await const MethodChannel( + 'ontime.d04.qa', + ).invokeMethod('ordinarySQLiteRejects', path) ?? + false + : await Isolate.run(() { + open.overrideForAll( + () => DynamicLibrary.open('/usr/lib/libsqlite3.dylib'), + ); + final raw = sqlite.sqlite3.open( + path, + mode: sqlite.OpenMode.readOnly, + ); + try { + if (raw.select('PRAGMA cipher_version').isNotEmpty) { + return false; + } + raw.select('SELECT note FROM users'); + return false; + } on sqlite.SqliteException { + return true; + } finally { + raw.dispose(); + } + }); + if (!plainRejected) { + throw StateError('Ordinary system SQLite refusal not proved'); + } + await storage.delete(key: slot, iOptions: options); + if (await storage.read(key: slot, iOptions: options) != null) { + throw StateError('Owned QA key cleanup readback failed'); + } + rows.add({ + 'from': version, + 'to': DatabaseSchemaContract.current, + 'originalTables': image.length, + 'backgroundOpenCount': 2, + 'cipherVersion': cipher, + 'sqliteVersion': engine, + 'syntheticSecureStorageReadback': true, + 'ownedKeyDeletionReadback': true, + 'wrongAndMissingKeyRejected': true, + 'ordinarySQLiteRejected': true, + }); + } + await result.writeAsString( + jsonEncode({ + 'success': true, + 'platform': Platform.operatingSystem, + 'contractDigest': databaseSchemaContractsDigest, + 'scope': + 'dedicated QA bundle using product AppDatabase/guards and background isolate', + 'physicalDeviceVerified': false, + 'productMainDiVerified': false, + 'powerLossVerified': false, + 'results': rows, + }), + flush: true, + ); + } catch (error) { + await result.writeAsString( + jsonEncode({ + 'success': false, + 'phase': phase, + 'errorType': error.runtimeType.toString(), + 'completed': rows, + }), + flush: true, + ); + } finally { + for (final slot in ownedSlots) { + await storage.delete(key: slot, iOptions: options); + } + } +} diff --git a/tool/qa/recovery_pair_crash_probe.dart b/tool/qa/recovery_pair_crash_probe.dart new file mode 100644 index 00000000..963a776d --- /dev/null +++ b/tool/qa/recovery_pair_crash_probe.dart @@ -0,0 +1,187 @@ +// Invoked only by verify_pair_crash_recovery.py with disposable synthetic data. +// Real host SQLCipher + process death. Disk-backed fixture secure storage and +// injected per-process identity do not establish native mobile plugin behavior. +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:drift/native.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/open.dart'; +import 'package:uuid/uuid.dart'; +import 'package:on_time_front/core/backup/backup_content.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/recovery/encrypted_pair_database.dart'; +import 'package:on_time_front/core/database/recovery/pair_files.dart'; +import 'package:on_time_front/core/database/recovery/pair_key_store.dart'; +import 'package:on_time_front/core/database/recovery/recovery_restore_service.dart'; +import 'package:on_time_front/core/database/recovery/store_pair.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + test('real encrypted pair survives killed host process', () async { + final directory = Directory(Platform.environment['D02_PROBE_DIR']!); + final point = Platform.environment['D02_PROBE_POINT']!; + final recovering = Platform.environment['D02_PROBE_MODE'] == 'recover'; + open.overrideForAll( + () => DynamicLibrary.open( + Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']!, + ), + ); + // This is a Flutter test entrypoint outside test/, with fixture-only prefs. + // ignore: invalid_use_of_visible_for_testing_member + SharedPreferences.setMockInitialValues({}); + final identity = const Uuid().v4(); + final files = PairFiles( + directory, + PairKeyStore( + storage: _DiskStorage(File('${directory.path}/fixture-keys.json')), + ), + protect: (_) async {}, + checkpoint: (stage) async { + if (!recovering && stage == point) { + await File('${directory.path}/cut-ready').writeAsString( + jsonEncode({'pid': pid, 'identity': identity}), + flush: true, + ); + await Completer().future; + } + }, + ); + final gate = LocalDataOperationGate(); + final owner = AlarmOperationCoordinator(gate); + final service = RecoveryRestoreService( + files, + gate: gate, + owner: owner, + processIdentity: () async => identity, + cleanupPlatform: () async {}, + repairCutover: () async {}, + ); + if (!recovering) { + await files + .database(const StorePair.legacy()) + .writeAsString('synthetic damaged original'); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'synthetic recovery sentinel', + ), + ); + final content = await BackupContent.capture( + db, + cutoff: DateTime.utc(2030), + sourceAppVersion: 'probe', + sourcePlatform: 'android', + ); + await db.close(); + final bytes = await BackupCrypto().encrypt( + plaintext: Uint8List.fromList( + utf8.encode(jsonEncode(content.toJson())), + ), + password: 'synthetic backup password', + ); + final candidate = await service.previewBytes( + bytes, + 'synthetic backup password', + ); + await service.activate(candidate); + fail( + 'Expected checkpoint must remain outstanding until controller kills this process', + ); + } else { + final old = + jsonDecode(await File('${directory.path}/cut-ready').readAsString()) + as Map; + expect(pid, isNot(old['pid'])); + expect(identity, isNot(old['identity'])); + expect(await files.database(const StorePair.legacy()).exists(), true); + if (point == 'manifest.written') { + await expectLater( + service.prepareStartup(), + throwsA(isA()), + ); + expect(await files.readManifest(), null); + expect((await service.resume()).followUpPending, false); + } else { + await service.prepareStartup(); + } + final target = await files.selected(); + final db = await openEncryptedPair(files, target); + expect( + (await db.select(db.users).getSingle()).note, + 'synthetic recovery sentinel', + ); + await db.close(); + expect(await files.database(const StorePair.legacy()).exists(), false); + expect((await files.readRecord())!.stage, PairStage.ready); + expect(gate.isAvailable, true); + await File('${directory.path}/verified').writeAsString( + jsonEncode({ + 'oldPid': old['pid'], + 'newPid': pid, + 'ready': true, + 'contentReadBack': true, + 'realSqlCipher': true, + }), + flush: true, + ); + } + }); +} + +class _DiskStorage extends FlutterSecureStorage { + _DiskStorage(this.file); + final File file; + Future> _all() async => await file.exists() + ? jsonDecode(await file.readAsString()) as Map + : {}; + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async => (await _all())[key] as String?; + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + final values = await _all(); + values[key] = value; + await file.writeAsString(jsonEncode(values), flush: true); + } + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + final values = await _all(); + values.remove(key); + await file.writeAsString(jsonEncode(values), flush: true); + } +} diff --git a/tool/qa/schedule_deletion_crash_probe.dart b/tool/qa/schedule_deletion_crash_probe.dart new file mode 100644 index 00000000..aaafe382 --- /dev/null +++ b/tool/qa/schedule_deletion_crash_probe.dart @@ -0,0 +1,135 @@ +// Synthetic host SQLCipher/process-death proof; never opens installed app data. +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/core/services/alarm_journal_store_native.dart'; +import 'package:on_time_front/core/services/alarm_registration_cleanup.dart'; +import 'package:sqlite3/open.dart'; + +import '../../test/helpers/schedule_deletion_workflow_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'committed deletion and file ownership survive actual host process death', + () async { + final directory = Platform.environment['U01_PROBE_DIR']; + final mode = Platform.environment['U01_PROBE_MODE']; + final library = Platform.environment['ONTIME_TEST_SQLCIPHER_LIBRARY']; + if (directory == null || + library == null || + !['cut', 'recover'].contains(mode)) { + throw StateError('Explicit synthetic probe environment required'); + } + final recovering = mode == 'recover'; + open.overrideForAll(() => DynamicLibrary.open(library)); + final db = AppDatabase.forTesting( + NativeDatabase( + File('$directory/store.sqlite'), + setup: (raw) => guardEncryptedDatabase( + raw, + '0909090909090909090909090909090909090909090909090909090909090909', + role: recovering + ? DatabaseOpenRole.activePairStartup + : DatabaseOpenRole.ownedCreation, + allowCreation: !recovering, + ), + ), + ); + final fixture = DeletionWorkflowFixture( + database: db, + journalStore: FileAlarmJournalStore( + () async => Directory('$directory/journal'), + ), + ); + addTearDown(fixture.close); + if (!recovering) { + await fixture.open(); + await fixture.create(); + fixture.registry.records = [fixture.record('one', 71)]; + final before = await fixture.revision(); + fixture.fallback.onCancel = (_) async { + expect(await db.select(db.schedules).get(), isEmpty); + expect(await fixture.revision(), before + 1); + final journal = await fixture.owner.journal.read(); + expect(journal.ownership, hasLength(1)); + final raw = await File( + '$directory/journal/ownership-v1.json', + ).readAsString(); + expect(raw, isNot(contains('private'))); + File('$directory/cut-ready').writeAsStringSync( + jsonEncode({ + 'point': 'after-commit-before-provider-return', + 'cutTestPid': pid, + 'revision': before + 1, + }), + flush: true, + ); + await Completer().future; + }; + await fixture.deletion.confirm(await fixture.deletion.prepare('one')); + fail('Provider checkpoint did not block'); + } + final checkpoint = + jsonDecode(File('$directory/cut-ready').readAsStringSync()) + as Map; + expect(await db.select(db.schedules).get(), isEmpty); + expect(await db.select(db.preparationSchedules).get(), isEmpty); + expect(await db.select(db.places).get(), isEmpty); + expect(await fixture.revision(), checkpoint['revision']); + expect((await fixture.owner.journal.read()).ownership, hasLength(1)); + expect(fixture.registry.records, isEmpty); + // Production shared recovery primitive, under a fresh owner. The complete + // mobile bootstrap/provider integration remains a separate device condition. + final cleanup = AlarmRegistrationCleanup( + fixture.registry, + fixture.scheduler, + fixture.fallback, + fixture.owner, + ); + final report = await fixture.owner.run( + fixture.owner.capture(), + () => cleanup.cancelMatchingReport(scheduleId: 'one'), + ); + expect(report.isComplete, isTrue); + expect( + fixture.fallback.cancelled.map( + (record) => record.fallbackNotificationId, + ), + [71], + ); + expect((await fixture.owner.journal.read()).ownership, isEmpty); + expect(await fixture.revision(), checkpoint['revision']); + expect(await db.select(db.schedules).get(), isEmpty); + expect(await db.customSelect('PRAGMA foreign_key_check').get(), isEmpty); + final cipher = + (await db.customSelect('PRAGMA cipher_version').getSingle()) + .data + .values + .single; + expect(cipher, isA()); + File('$directory/verified').writeAsStringSync( + jsonEncode({ + 'recoverTestPid': pid, + 'cipherVersion': cipher, + 'actualCipher': true, + 'deletedRowsRemainAbsent': true, + 'replayedOwnership': 1, + 'revisionUnchangedDuringRecovery': true, + 'provider': 'fake local notification port', + 'recoveryScope': + 'production shared cleanup primitive; not full mobile bootstrap', + 'mobileOsVerified': false, + 'powerLossVerified': false, + }), + flush: true, + ); + }, + ); +} diff --git a/tool/qa/time_zone_native_probe.dart b/tool/qa/time_zone_native_probe.dart new file mode 100644 index 00000000..dbe933ae --- /dev/null +++ b/tool/qa/time_zone_native_probe.dart @@ -0,0 +1,684 @@ +// Separate synthetic QA bundle only. This is not the production entry point. +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/encrypted_database_guard.dart'; +import 'package:on_time_front/core/database/local_data_files.dart'; +import 'package:on_time_front/core/database/local_data_operation_gate.dart'; +import 'package:on_time_front/core/database/restore_runtime_identity.dart'; +import 'package:on_time_front/core/database/sqlcipher_loader.dart'; +import 'package:on_time_front/core/services/alarm_journal_store.dart'; +import 'package:on_time_front/core/services/alarm_operation_coordinator.dart'; +import 'package:on_time_front/core/services/alarm_ownership_journal.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/local_time_zone_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/core/time/device_civil_day.dart'; +import 'package:on_time_front/core/time/schedule_time_resolution.dart'; +import 'package:on_time_front/core/time/time_zone_rules.dart'; +import 'package:on_time_front/data/data_sources/alarm_registry_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_with_time_local_data_source.dart'; +import 'package:on_time_front/data/repositories/alarm_registry_repository_impl.dart'; +import 'package:on_time_front/data/repositories/alarm_repository_impl.dart'; +import 'package:on_time_front/data/repositories/preparation_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_aggregate_repository_impl.dart'; +import 'package:on_time_front/data/repositories/recurring_schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.dart'; +import 'package:on_time_front/data/repositories/timed_preparation_repository_impl.dart'; +import 'package:on_time_front/data/repositories/user_repository_impl.dart'; +import 'package:on_time_front/domain/entities/civil_date_time.dart'; +import 'package:on_time_front/domain/entities/delivery_observation.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; + +const _runId = String.fromEnvironment('ONTIME_A10_QA_RUN'); +const _actions = ['seed', 'observe', 'start', 'reconcile', 'cleanup']; + +class _ProbeCheckFailure implements Exception { + const _ProbeCheckFailure(this.check); + final String check; +} + +void _require(bool condition, String check) { + if (!condition) throw _ProbeCheckFailure(check); +} + +Object? _jsonValue(Object? value) => + value is DateTime ? value.toUtc().toIso8601String() : value.toString(); +String _encode(Object? value) => jsonEncode(value, toEncodable: _jsonValue); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final package = await PackageInfo.fromPlatform(); + if (!RegExp(r'^[a-zA-Z0-9-]{1,48}$').hasMatch(_runId) || + !(package.packageName == 'club.devkor.ontime.qa.a10' || + package.packageName.startsWith('club.devkor.ontime.qa.a10.'))) { + runApp( + const MaterialApp( + home: Scaffold( + body: Center( + child: Text( + 'A10 QA bundle and run identity required. No data opened.', + ), + ), + ), + ), + ); + return; + } + // Production registry/runtime implementations, isolated within this QA run. + SharedPreferences.setPrefix('a10_time_zone_probe.$_runId.'); + TimeZoneRules.ensureInitialized(); + final documents = await getApplicationDocumentsDirectory(); + final root = Directory( + '${(await getApplicationSupportDirectory()).path}/a10-time-zone-probe/$_runId', + ); + await root.create(recursive: true); + await excludeLocalDatabaseFromPlatformBackup(File(root.path)); + final command = File('${documents.path}/a10-probe-command.json'); + var action = 'observe'; + String? target; + if (await command.exists()) { + final value = + jsonDecode(await command.readAsString()) as Map; + _require(value['run'] == _runId, 'command run mismatch'); + action = value['action'] as String; + target = value['targetUtc'] as String?; + } + final probe = _Probe(root, documents, package.packageName); + runApp(_ProbeApp(probe, action, target)); +} + +class _ProbeApp extends StatefulWidget { + const _ProbeApp(this.probe, this.initialAction, this.target); + final _Probe probe; + final String initialAction; + final String? target; + @override + State<_ProbeApp> createState() => _ProbeAppState(); +} + +class _ProbeAppState extends State<_ProbeApp> { + bool busy = false; + String result = 'Starting synthetic QA probe'; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => perform(widget.initialAction), + ); + } + + Future perform(String action) async { + if (busy) return; + setState(() => busy = true); + final value = await widget.probe.perform( + action, + targetLiteral: widget.target, + ); + if (mounted) { + setState(() { + result = const JsonEncoder.withIndent(' ').convert(value); + busy = false; + }); + } + } + + @override + Widget build(BuildContext context) => MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('A10 synthetic native probe')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Run: $_runId\nOnly the dedicated QA installation is used. This screen is not home UI or notification-tap evidence.', + ), + Wrap( + spacing: 8, + children: [ + for (final action in _actions) + FilledButton( + onPressed: busy ? null : () => perform(action), + child: Text(action), + ), + ], + ), + if (busy) const LinearProgressIndicator(), + SelectableText(result), + ], + ), + ), + ), + ); +} + +class _Probe { + _Probe(this.root, this.documents, this.packageName); + final Directory root, documents; + final String packageName; + String get explicitId => 'a10-$_runId-explicit'; + String get legacyId => 'a10-$_runId-unique-null'; + File get dbFile => File('${root.path}/synthetic.sqlite'); + File get keyFile => File('${root.path}/synthetic-key.txt'); + File get checkpoint => File('${root.path}/checkpoint.json'); + + Future> _raw(AppDatabase db) async => { + 'schedules': [ + for (final r + in await db.customSelect('SELECT * FROM schedules ORDER BY id').get()) + r.data, + ], + 'users': [ + for (final r + in await db.customSelect('SELECT * FROM users ORDER BY id').get()) + r.data, + ], + 'preparationDefinitions': [ + for (final r + in await db + .customSelect('SELECT * FROM preparation_definitions ORDER BY id') + .get()) + r.data, + ], + 'preparationSteps': [ + for (final r + in await db + .customSelect( + 'SELECT * FROM preparation_definition_steps ORDER BY id', + ) + .get()) + r.data, + ], + }; + String _digest(Object value) => + sha256.convert(utf8.encode(_encode(value))).toString(); + Map _observation(DeliveryObservation value) => { + 'source': value.source.name, + 'available': value.available, + 'isOsObservation': value.isOsObservation, + 'unmappedCount': value.unmappedCount, + 'entries': [ + for (final entry in value.entries) + {'id': entry.id, 'scheduleId': entry.scheduleId}, + ], + }; + + Future> perform( + String action, { + String? targetLiteral, + }) async { + final output = { + 'run': _runId, + 'action': action, + 'packageName': packageName, + 'platform': Platform.operatingSystem, + 'processId': pid, + 'observedAtUtc': DateTime.now().toUtc().toIso8601String(), + 'completed': false, + 'scope': + 'Real native zone channel, SQLCipher and production repository/reconcile implementations in a synthetic QA bundle. Home UI, notification taps and physical-device status require separate evidence.', + }; + var phase = 'validate action'; + AppDatabase? db; + ScheduleRepositoryImpl? schedules; + PreparationRepositoryImpl? preparation; + UserRepositoryImpl? users; + AlarmOperationCoordinator? operations; + try { + _require(_actions.contains(action), 'unknown action'); + phase = 'native zone'; + final zone = await LocalTimeZoneService.current(); + output['nativeZone'] = zone; + final localNow = DateTime.now(); + output['dartDeviceOffsetSeconds'] = localNow.timeZoneOffset.inSeconds; + output['dartDeviceTimeZoneName'] = localNow.timeZoneName; + output['loadedRulesIdentity'] = TimeZoneRules.loadedIdentity; + final creating = action == 'seed'; + if (creating) { + _require( + !await dbFile.exists() && + !await keyFile.exists() && + !await checkpoint.exists(), + 'seed never overwrites an existing run', + ); + final random = Random.secure(); + final key = List.generate( + 32, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + await keyFile.writeAsString(key, flush: true); + await excludeLocalDatabaseFromPlatformBackup(keyFile); + } else { + _require( + await dbFile.exists() && + await keyFile.exists() && + await checkpoint.exists(), + 'seed required', + ); + } + phase = 'open actual SQLCipher'; + final key = await keyFile.readAsString(); + configureSqlCipherLoader(); + db = AppDatabase.forTesting( + NativeDatabase( + dbFile, + setup: (raw) => guardEncryptedDatabase( + raw, + key, + role: creating + ? DatabaseOpenRole.ownedCreation + : DatabaseOpenRole.activePairStartup, + allowCreation: creating, + ), + ), + ); + final cipher = await db.customSelect('PRAGMA cipher_version').getSingle(); + output['cipherVersion'] = cipher.data.values.single; + await excludeLocalDatabaseFromPlatformBackup(dbFile); + final recurring = RecurringScheduleRepositoryImpl(db); + final timed = TimedPreparationRepositoryImpl( + localDataSource: PreparationWithTimeLocalDataSourceImpl(), + ); + schedules = ScheduleRepositoryImpl( + database: db, + timedPreparationRepository: timed, + recurringScheduleRepository: recurring, + ); + if (creating) { + phase = 'seed fixed synthetic targets'; + final requested = targetLiteral == null + ? DateTime.now().toUtc().add(const Duration(hours: 3)) + : DateTime.parse(targetLiteral); + _require( + requested.isUtc && + requested.isAfter( + DateTime.now().toUtc().add(const Duration(minutes: 10)), + ), + 'target must be explicit future UTC', + ); + final target = DateTime.utc( + requested.year, + requested.month, + requested.day, + requested.hour, + requested.minute, + requested.second, + 123, + 456, + ); + await db.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 5), + note: 'A10 synthetic profile', + isOnboardingCompleted: true, + ), + ); + for (final legacy in [false, true]) { + final instant = target.add(Duration(minutes: legacy ? 15 : 0)); + final civil = CivilDateTime.fromFields( + tz.TZDateTime.from(instant, tz.getLocation('Asia/Seoul')), + ).toUtcCarrier(); + final id = legacy ? legacyId : explicitId; + await schedules.createSchedule( + ScheduleEntity( + id: id, + place: PlaceEntity( + id: '$id-place', + placeName: 'Synthetic QA place', + ), + scheduleName: legacy ? 'A10 unique null' : 'A10 explicit', + scheduleTime: civil, + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: legacy ? null : 32400, + moveTime: const Duration(minutes: 2), + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: 'Synthetic precision fixture', + ), + ); + } + await db.userDao.updateAlarmSettings( + userId: 'local-profile', + enabled: true, + ); + output['seedTargetUtc'] = target.toIso8601String(); + } + await RestoreRuntimeIdentity.shared.load(db); + final before = await _raw(db); + final saved = creating + ? null + : jsonDecode(await checkpoint.readAsString()) as Map; + if (saved != null) { + _require( + _digest(before) == saved['portableDigest'], + 'reopen or zone change modified portable data', + ); + } + output['beforeDigest'] = _digest(before); + phase = 'read schedule and runtime'; + Future> read(String id) async { + final row = await schedules!.getScheduleById(id); + final resolution = ScheduleTimeResolver.resolve( + row, + nowUtc: DateTime.now().toUtc(), + ); + final instant = resolution.instantUtc; + _require(instant != null, 'synthetic target became unresolved'); + final prep = await readSchedulePreparation(db!, row); + final runtime = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + row, + PreparationWithTimeEntity.fromPreparation(prep), + timeResolution: resolution, + ); + final device = instant!.toLocal(); + final day = DeviceCivilDay.at(instant); + final cached = await timed.getTimedPreparationSnapshot(id); + return { + 'id': id, + 'civil': CivilDateTime.fromFields( + row.scheduleTime, + ).toCivilIso8601String(), + 'zone': row.timeZoneId, + 'storedOffsetSeconds': row.occurrenceOffsetSeconds, + 'instantUtc': instant.toIso8601String(), + 'fingerprint': runtime.cacheFingerprint, + 'deviceCivil': CivilDateTime.fromFields( + device, + ).toCivilIso8601String(), + 'deviceOffsetSeconds': device.timeZoneOffset.inSeconds, + 'deviceDayStartUtc': day.startUtc.toIso8601String(), + 'deviceDayEndUtc': day.endUtc.toIso8601String(), + 'deviceDayHours': day.endUtc.difference(day.startUtc).inMinutes / 60, + 'startedAtUtc': row.startedAt?.toUtc().toIso8601String(), + 'frozen': row.preparationFrozen, + 'isStarted': row.isStarted, + 'cachedFingerprint': cached?.scheduleFingerprint, + 'cachedStartedAtUtc': cached?.startedAt?.toUtc().toIso8601String(), + 'cacheRequiresConfirmation': cached?.requiresConfirmation, + }; + } + + final beforeRecords = [await read(explicitId), await read(legacyId)]; + if (saved != null) { + final records = saved['records'] as List; + for (var i = 0; i < records.length; i++) { + _require( + beforeRecords[i]['instantUtc'] == records[i]['instantUtc'], + 'target instant changed', + ); + _require( + beforeRecords[i]['fingerprint'] == records[i]['fingerprint'], + 'runtime fingerprint changed across restart or zone', + ); + for (final field in [ + 'cachedFingerprint', + 'cachedStartedAtUtc', + 'cacheRequiresConfirmation', + ]) { + _require( + beforeRecords[i][field] == records[i][field], + 'cached runtime changed across restart or zone', + ); + } + } + } + _require( + _digest(await _raw(db)) == _digest(before), + 'read changed portable data', + ); + output['readOnlyInvariant'] = true; + if (action == 'start') { + phase = 'public first start and durable runtime'; + final fingerprint = beforeRecords.last['fingerprint']; + final hadStart = beforeRecords.last['startedAtUtc'] != null; + final beforeRevision = + ((before['users'] as List).single as Map)['data_revision'] as int; + final started = await schedules.startSchedule( + legacyId, + startedAt: DateTime.now().toUtc(), + ); + final row = await schedules.getScheduleById(legacyId); + final resolved = ScheduleTimeResolver.resolve( + row, + nowUtc: DateTime.now().toUtc(), + ); + final prep = PreparationWithTimeEntity.fromPreparation( + await readSchedulePreparation(db, row), + ); + final runtime = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + row, + prep, + timeResolution: resolved, + ); + _require( + row.occurrenceOffsetSeconds == 32400 && + row.preparationFrozen && + row.startedAt?.toUtc() == started.toUtc(), + 'first start fact mismatch', + ); + _require( + runtime.cacheFingerprint == fingerprint, + 'first start changed target fingerprint', + ); + await timed.saveTimedPreparationSnapshot( + legacyId, + TimedPreparationSnapshotEntity( + preparation: prep, + savedAt: DateTime.now().toUtc(), + startedAt: started, + scheduleFingerprint: runtime.cacheFingerprint, + ), + ); + final committedRaw = await _raw(db); + final afterRevision = + ((committedRaw['users'] as List).single as Map)['data_revision'] + as int; + _require( + afterRevision == beforeRevision + (hadStart ? 0 : 1), + 'start revision changed unexpectedly', + ); + output['startRevisionDelta'] = afterRevision - beforeRevision; + final committed = _digest(committedRaw); + final repeated = await schedules.startSchedule( + legacyId, + startedAt: DateTime.now().toUtc(), + ); + _require( + repeated.toUtc() == started.toUtc() && + _digest(await _raw(db)) == committed, + 'repeated start changed facts', + ); + output['startReceiptUtc'] = started.toUtc().toIso8601String(); + output['startIdempotent'] = true; + } + if (action == 'reconcile' || action == 'cleanup') { + phase = 'native delivery owner'; + final registry = AlarmRegistryRepositoryImpl( + localDataSource: AlarmRegistryLocalDataSourceImpl(), + ); + _require( + (await registry.loadAll()).every( + (r) => r.scheduleId.startsWith('a10-$_runId-'), + ), + 'foreign registry ownership', + ); + operations = AlarmOperationCoordinator( + LocalDataOperationGate.shared, + journal: AlarmOwnershipJournal( + FileAlarmJournalStore( + () async => Directory('${root.path}/alarm-journal'), + excludeFromBackup: (path) => + excludeLocalDatabaseFromPlatformBackup(File(path)), + ), + ), + ); + // Actual platform plugin; the injectable constructor only selects the + // isolated durable owner. No scheduling/provider method is mocked. + // ignore: invalid_use_of_visible_for_testing_member + final notifications = NotificationService.test( + localNotifications: FlutterLocalNotificationsPlugin(), + alarmOwner: operations, + ); + await notifications.initialize(); + final scheduler = AlarmSchedulerService(); + final fallback = FallbackAlarmNotificationServiceImpl( + notificationService: notifications, + ); + if (action == 'cleanup') { + await CancelAllAlarmsUseCase( + registry, + scheduler, + fallback, + operations: operations, + ).call(); + } else { + users = UserRepositoryImpl(db); + preparation = PreparationRepositoryImpl( + preparationLocalDataSource: PreparationLocalDataSourceImpl( + appDatabase: db, + ), + userRepository: users, + database: db, + ); + final alarmRepository = AlarmRepositoryImpl( + database: db, + scheduleRepository: schedules, + preparationRepository: preparation, + recurringScheduleRepository: recurring, + ); + final result = await ReconcileAlarmsUseCase( + alarmRepository, + registry, + scheduler, + fallback, + operations: operations, + ).call(); + output['reconciliation'] = { + 'status': result.status.name, + 'permissionIssue': result.permissionIssue?.name, + 'armedIds': result.armedScheduleIds, + 'skippedCount': result.skippedScheduleCount, + 'failures': [ + for (final failure in result.failures) + { + 'scheduleId': failure.scheduleId, + 'reason': failure.reason.name, + }, + ], + }; + } + output['registry'] = [ + for (final record in await registry.loadAll()) + { + 'scheduleId': record.scheduleId, + 'provider': record.provider.name, + 'alarmTimeUtc': record.alarmTime.toUtc().toIso8601String(), + 'preparationStartUtc': record.preparationStartTime + .toUtc() + .toIso8601String(), + 'fingerprint': record.scheduleFingerprint, + 'cancellationPending': record.cancellationPending, + 'timing': record.notificationTiming?.name, + }, + ]; + output['fallbackObservation'] = _observation( + await fallback.observePending(), + ); + output['nativeObservation'] = _observation( + await scheduler.observePendingNativeAlarms([explicitId, legacyId]), + ); + } + phase = 'final readback'; + final after = await _raw(db); + if (action != 'start') { + _require( + _digest(before) == _digest(after), + 'read or reconcile changed portable facts', + ); + } + output['afterDigest'] = _digest(after); + output['records'] = [await read(explicitId), await read(legacyId)]; + final state = { + 'portableDigest': output['afterDigest'], + 'records': output['records'], + }; + await checkpoint.writeAsString(_encode(state), flush: true); + output['completed'] = true; + } catch (error) { + output['failurePhase'] = phase; + // Never print SQL, key bytes, arbitrary platform messages or host paths. + output['failureType'] = error.runtimeType.toString(); + if (error is _ProbeCheckFailure) output['failedCheck'] = error.check; + } finally { + final failures = []; + for (final close in Function()>[ + () async { + await preparation?.dispose(); + }, + () async { + await users?.dispose(); + }, + () async { + await schedules?.dispose(); + }, + () async { + await db?.close(); + }, + () async { + operations?.dispose(); + }, + ]) { + try { + await close(); + } catch (error) { + failures.add(error.runtimeType.toString()); + } + } + output['closed'] = failures.isEmpty; + if (failures.isNotEmpty) { + output['completed'] = false; + output['cleanupFailureTypes'] = failures; + } + } + final encoded = _encode(output); + print('A10_NATIVE_PROBE $encoded'); + final stamp = DateTime.now().toUtc().microsecondsSinceEpoch; + try { + await File( + '${root.path}/observation-$stamp-$action.json', + ).writeAsString(encoded, flush: true); + await File( + '${documents.path}/a10-probe-result.json', + ).writeAsString(encoded, flush: true); + } catch (error) { + output['completed'] = false; + output['evidenceWriteFailureType'] = error.runtimeType.toString(); + print('A10_NATIVE_PROBE ${_encode(output)}'); + } + return output; + } +} diff --git a/tool/qa/verify_migration_crash_recovery.py b/tool/qa/verify_migration_crash_recovery.py new file mode 100644 index 00000000..3d5129dd --- /dev/null +++ b/tool/qa/verify_migration_crash_recovery.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""SIGKILL/reopen only synthetic host SQLCipher stores; no installed app data.""" +import argparse +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--output', required=True) + parser.add_argument('--flutter', default='flutter') + args = parser.parse_args() + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + root = Path(__file__).resolve().parents[2] + command = [args.flutter, 'test', 'tool/qa/migration_crash_probe.dart', '--no-pub', '--reporter', 'expanded'] + results = [] + for point in ['after-first-ddl', 'before-version', 'after-version', 'after-commit']: + with tempfile.TemporaryDirectory(prefix='ontime-d04-kill-') as temporary: + directory = Path(temporary) + env = {**os.environ, 'D04_PROBE_DIR': temporary, 'D04_PROBE_POINT': point, 'D04_PROBE_MODE': 'cut'} + with (output / f'{point}-cut.log').open('w') as log: + child = subprocess.Popen(command, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + deadline = time.monotonic() + 90 + while not (directory / 'cut-ready').exists(): + if child.poll() is not None: + raise RuntimeError(f'{point}: exited before checkpoint; inspect log') + if time.monotonic() > deadline: + raise TimeoutError(f'{point}: checkpoint timeout') + time.sleep(0.1) + ready = (directory / 'cut-ready').read_text() + if ready != point: + raise RuntimeError(f'{point}: wrong ready checkpoint {ready!r}') + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGKILL) + cut_code = child.wait() + if cut_code != -signal.SIGKILL: + raise RuntimeError(f'{point}: expected actual SIGKILL, got {cut_code}') + env['D04_PROBE_MODE'] = 'recover' + with (output / f'{point}-recover.log').open('w') as log: + recovered = subprocess.Popen(command, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT) + recovered.wait(timeout=120) + if recovered.returncode or not (directory / 'verified').exists(): + raise RuntimeError(f'{point}: recovery failed; inspect log') + results.append({'cut': point, 'ready': ready, 'signal': 'SIGKILL', 'cutPid': child.pid, 'cutExitCode': cut_code, 'recoverPid': recovered.pid, 'recoverExitCode': recovered.returncode, 'command': command, **json.loads((directory / 'verified').read_text())}) + print(f'{point}: passed', flush=True) + (output / 'result.json').write_text(json.dumps({'scope': 'actual host SQLCipher process death', 'results': results}, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/tool/qa/verify_pair_crash_recovery.py b/tool/qa/verify_pair_crash_recovery.py new file mode 100644 index 00000000..1a7a2fc1 --- /dev/null +++ b/tool/qa/verify_pair_crash_recovery.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Actual host process death with SQLCipher; fixture key storage/native identity. +Does not claim mobile plugin, provider transfer, directory fsync, or power loss. +""" +import argparse +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--output', required=True) + args = parser.parse_args() + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + root = Path(__file__).resolve().parents[2] + command = ['flutter', 'test', 'tool/qa/recovery_pair_crash_probe.dart', '--reporter', 'expanded'] + results = [] + for point in ['manifest.written', 'record.activated.readBack']: + with tempfile.TemporaryDirectory(prefix='ontime-pair-kill-') as temporary: + directory = Path(temporary) + env = {**os.environ, 'D02_PROBE_DIR': temporary, 'D02_PROBE_POINT': point, 'D02_PROBE_MODE': 'cut'} + with (output / f'{point}-cut.log').open('w') as log: + child = subprocess.Popen(command, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + deadline = time.monotonic() + 90 + while not (directory / 'cut-ready').exists(): + if child.poll() is not None: + raise RuntimeError(f'{point}: exited before checkpoint; inspect log') + if time.monotonic() > deadline: + raise TimeoutError(f'{point}: checkpoint timeout') + time.sleep(0.1) + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGKILL) + child.wait() + env['D02_PROBE_MODE'] = 'recover' + with (output / f'{point}-recover.log').open('w') as log: + recovered = subprocess.run(command, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=120) + if recovered.returncode or not (directory / 'verified').exists(): + raise RuntimeError(f'{point}: recovery failed; inspect log') + results.append({'cut': point, 'signal': 'SIGKILL', **json.loads((directory / 'verified').read_text())}) + print(f'{point}: passed', flush=True) + (output / 'result.json').write_text(json.dumps({'scope': 'actual host process death; disk fixture key storage and injected process identity', + 'mobileOsVerified': False, 'powerLossVerified': False, 'results': results}, indent=2) + '\n') + +if __name__ == '__main__': + main() diff --git a/tool/qa/verify_schedule_deletion_crash_recovery.py b/tool/qa/verify_schedule_deletion_crash_recovery.py new file mode 100644 index 00000000..959bec52 --- /dev/null +++ b/tool/qa/verify_schedule_deletion_crash_recovery.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""SIGKILL/reopen a synthetic host SQLCipher store after deletion commits. + +Uses a fake provider and the production shared cleanup primitive. This does not +prove mobile delivery, full app bootstrap, power-loss or syscall interruption. +""" +import argparse +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--flutter', default='flutter') + parser.add_argument('--output', required=True) + args = parser.parse_args() + if not os.environ.get('ONTIME_TEST_SQLCIPHER_LIBRARY'): + raise RuntimeError('Explicit SQLCipher test library required') + root = Path(__file__).resolve().parents[2] + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + command = [args.flutter, 'test', '--no-pub', '--reporter', 'expanded', + 'tool/qa/schedule_deletion_crash_probe.dart'] + with tempfile.TemporaryDirectory(prefix='ontime-u01-kill-') as tmp: + directory = Path(tmp) + env = {**os.environ, 'U01_PROBE_DIR': tmp, 'U01_PROBE_MODE': 'cut'} + with (output / 'cut.log').open('w') as log: + child = subprocess.Popen(command, cwd=root, env=env, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) + ready = None + try: + deadline = time.monotonic() + 90 + while ready is None: + if child.poll() is not None: + raise RuntimeError('Probe exited before checkpoint; inspect cut.log') + try: + ready = json.loads((directory / 'cut-ready').read_text()) + except (FileNotFoundError, json.JSONDecodeError): + if time.monotonic() > deadline: + raise TimeoutError('Deletion checkpoint not reached') + time.sleep(0.1) + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGKILL) + cut_code = child.wait() + if ready is None or cut_code != -signal.SIGKILL: + raise RuntimeError(f'Actual checkpoint SIGKILL not verified: {cut_code}') + env['U01_PROBE_MODE'] = 'recover' + with (output / 'recover.log').open('w') as log: + recovered = subprocess.run(command, cwd=root, env=env, stdout=log, + stderr=subprocess.STDOUT, timeout=120) + text = (output / 'recover.log').read_text() + if (recovered.returncode != 0 or 'All tests passed!' not in text + or '[E]' in text or not (directory / 'verified').is_file()): + raise RuntimeError('Fresh-process recovery failed; inspect recover.log') + verified = json.loads((directory / 'verified').read_text()) + if ready['cutTestPid'] == verified['recoverTestPid']: + raise RuntimeError('Recovery must run in a distinct test process') + result = {'command': command, 'signal': 'SIGKILL', 'cutExitCode': cut_code, + 'recoverExitCode': recovered.returncode, **ready, **verified} + (output / 'result.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tool/qa/verify_schema_gate.py b/tool/qa/verify_schema_gate.py new file mode 100644 index 00000000..7514fecc --- /dev/null +++ b/tool/qa/verify_schema_gate.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Run the real gate against isolated valid/invalid inputs and its old generated output.""" +import argparse +import copy +import json +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--dart', default='dart') + parser.add_argument('--output', required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + source_path = Path('lib/core/database/schema_contracts.schema.json') + source = (root / source_path).read_text() + original = json.loads(source) + cases = ['valid', 'missing_fixture', 'corrupt_ddl', 'wrong_objects', 'empty_pair', 'duplicate_pair', 'non_integer_pair', 'missing_current', 'duplicate_snapshot', 'stale_generated'] + results = [] + for case in cases: + with tempfile.TemporaryDirectory(prefix='ontime-d04-gate-') as temporary: + cwd = Path(temporary) + shutil.copytree(root / 'test/fixtures/database', cwd / 'test/fixtures/database') + target = cwd / source_path + target.parent.mkdir(parents=True) + data = copy.deepcopy(original) + if case == 'wrong_objects': data['snapshots'][0]['objects'].pop() + if case == 'empty_pair': data['pairSupported'] = [] + if case == 'duplicate_pair': data['pairSupported'] = [4, 4] + if case == 'non_integer_pair': data['pairSupported'] = [4, '4'] + if case == 'missing_current': data['pairSupported'] = [3] + if case == 'duplicate_snapshot': data['snapshots'].append(copy.deepcopy(data['snapshots'][0])) + if case == 'stale_generated': data['auditNote'] = 'new valid input without regenerated output' + target.write_text(source if case == 'valid' else json.dumps(data, ensure_ascii=False, indent=2) + '\n') + if case == 'missing_fixture': (cwd / 'test/fixtures/database/schema_v1.sql').unlink() + if case == 'corrupt_ddl': (cwd / 'test/fixtures/database/schema_v1.sql').write_text('PRAGMA user_version=1;') + command = [args.dart, '--packages=' + str(root / '.dart_tool/package_config.json'), str(root / 'tool/check_database_schema.dart')] + result = subprocess.run(command, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60) + (output / f'{case}.log').write_text(result.stdout.replace(str(cwd), '')) + expected = result.returncode == 0 if case == 'valid' else result.returncode != 0 + results.append({'case': case, 'exitCode': result.returncode, 'expectedOutcome': expected}) + if not expected: raise RuntimeError(f'{case}: unexpected gate result') + (output / 'result.json').write_text(json.dumps(results, indent=2) + '\n') + print(f'{len(results)} actual gate command cases passed') + + +if __name__ == '__main__': + main() diff --git a/widgetbook/linux/flutter/generated_plugin_registrant.cc b/widgetbook/linux/flutter/generated_plugin_registrant.cc index 7ceedb9c..6a4d8c0f 100644 --- a/widgetbook/linux/flutter/generated_plugin_registrant.cc +++ b/widgetbook/linux/flutter/generated_plugin_registrant.cc @@ -6,22 +6,14 @@ #include "generated_plugin_registrant.h" -#include #include -#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); - file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); - g_autoptr(FlPluginRegistrar) sodium_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin"); - sodium_libs_plugin_register_with_registrar(sodium_libs_registrar); g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar); diff --git a/widgetbook/linux/flutter/generated_plugins.cmake b/widgetbook/linux/flutter/generated_plugins.cmake index 5d4b63a2..771d30fb 100644 --- a/widgetbook/linux/flutter/generated_plugins.cmake +++ b/widgetbook/linux/flutter/generated_plugins.cmake @@ -3,9 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST - file_selector_linux flutter_secure_storage_linux - sodium_libs sqlcipher_flutter_libs url_launcher_linux ) diff --git a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift index d87e6546..5cd062d4 100644 --- a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,24 +5,20 @@ import FlutterMacOS import Foundation -import file_selector_macos import flutter_local_notifications import flutter_secure_storage_darwin import package_info_plus import path_provider_foundation import shared_preferences_foundation -import sodium_libs import sqlcipher_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/widgetbook/pubspec.lock b/widgetbook/pubspec.lock index 78ca20b6..e3c7ecaf 100644 --- a/widgetbook/pubspec.lock +++ b/widgetbook/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.0.0" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -136,6 +144,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: @@ -160,22 +176,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 - url: "https://pub.dev" - source: hosted - version: "0.3.5+5" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -272,70 +280,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_selector: - dependency: transitive - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.dev" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206" - url: "https://pub.dev" - source: hosted - version: "0.5.2+10" - file_selector_ios: - dependency: transitive - description: - name: file_selector_ios - sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" - url: "https://pub.dev" - source: hosted - version: "0.5.3+6" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab - url: "https://pub.dev" - source: hosted - version: "0.9.4+1" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 - url: "https://pub.dev" - source: hosted - version: "0.9.5+1" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec - url: "https://pub.dev" - source: hosted - version: "0.9.3+6" fixnum: dependency: transitive description: @@ -524,6 +468,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" html: dependency: transitive description: @@ -851,6 +803,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: transitive description: @@ -875,6 +835,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" resizable_widget: dependency: transitive description: @@ -980,18 +948,10 @@ packages: dependency: transitive description: name: sodium - sha256: "515b86c186f4caca49051caf858d878ca7cc4ff4542411e9febb50654eac8a62" - url: "https://pub.dev" - source: hosted - version: "3.4.6" - sodium_libs: - dependency: transitive - description: - name: sodium_libs - sha256: f3f9c516b4183226b7a08ca43a765ebc9e02cfd92e46e8a6cc490f98ffe73052 + sha256: "3d8bec7acb3bd958309e431a840f1eded82f508d57461ecd2920d399b802b38a" url: "https://pub.dev" source: hosted - version: "3.4.6+4" + version: "4.0.4" source_gen: dependency: transitive description: @@ -1028,10 +988,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" url: "https://pub.dev" source: hosted - version: "2.7.6" + version: "2.9.4" stack_trace: dependency: transitive description: @@ -1064,14 +1024,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" - url: "https://pub.dev" - source: hosted - version: "3.4.1+2" table_calendar: dependency: transitive description: diff --git a/widgetbook/windows/flutter/generated_plugin_registrant.cc b/widgetbook/windows/flutter/generated_plugin_registrant.cc index 0e239095..2f656806 100644 --- a/widgetbook/windows/flutter/generated_plugin_registrant.cc +++ b/widgetbook/windows/flutter/generated_plugin_registrant.cc @@ -6,22 +6,16 @@ #include "generated_plugin_registrant.h" -#include #include #include -#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - FileSelectorWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - SodiumLibsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SodiumLibsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/widgetbook/windows/flutter/generated_plugins.cmake b/widgetbook/windows/flutter/generated_plugins.cmake index c2cd331d..d205fd58 100644 --- a/widgetbook/windows/flutter/generated_plugins.cmake +++ b/widgetbook/windows/flutter/generated_plugins.cmake @@ -3,10 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST - file_selector_windows flutter_secure_storage_windows permission_handler_windows - sodium_libs sqlcipher_flutter_libs url_launcher_windows ) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 33665946..b2f4dc78 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,14 +6,11 @@ #include "generated_plugin_registrant.h" -#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - FileSelectorWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d966e7c9..fd6be38b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - file_selector_windows flutter_secure_storage_windows permission_handler_windows sqlcipher_flutter_libs From 6b5ca7700662e604ab571d5d821ed412fc378461 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Fri, 25 Sep 2026 19:27:54 +0900 Subject: [PATCH 2/3] test: allow long recurrence boundary setup in CI --- test/data/repositories/nearest_query_sql_boundaries_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/data/repositories/nearest_query_sql_boundaries_test.dart b/test/data/repositories/nearest_query_sql_boundaries_test.dart index 944ac307..5b72462e 100644 --- a/test/data/repositories/nearest_query_sql_boundaries_test.dart +++ b/test/data/repositories/nearest_query_sql_boundaries_test.dart @@ -297,6 +297,9 @@ void main() { await sub.cancel(); } }, + timeout: monthly && segments == 32 + ? const Timeout(Duration(minutes: 2)) + : null, ); } } From bbc3ea2c5eb88ae9fe07fce44a539c3f3bb58daf Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Fri, 25 Sep 2026 19:41:29 +0900 Subject: [PATCH 3/3] test: subscribe before schedule timer transition --- .../presentation/app/bloc/schedule/schedule_bloc_test.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart index ccf11546..8d65fb91 100644 --- a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart +++ b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart @@ -1471,14 +1471,15 @@ void main() { controller.add(scheduleA); await Future.delayed(const Duration(milliseconds: 20)); controller.add(scheduleB); + final startedFuture = bloc.stream.firstWhere( + (s) => s.status == ScheduleStatus.started && s.schedule?.id == 'B', + ); await Future.delayed(const Duration(milliseconds: 220)); expect(bloc.state.schedule?.id, 'B'); expect(bloc.state.status, ScheduleStatus.upcoming); - final started = await bloc.stream.firstWhere( - (s) => s.status == ScheduleStatus.started, - ); + final started = await startedFuture; expect(started.schedule?.id, 'B'); expect(navigationService.pushedRoutes, ['/scheduleStart']); },