diff --git a/.github/codeql/codeql-kotlin-compat.init.gradle b/.github/codeql/codeql-kotlin-compat.init.gradle index 5e7a357..be39914 100644 --- a/.github/codeql/codeql-kotlin-compat.init.gradle +++ b/.github/codeql/codeql-kotlin-compat.init.gradle @@ -1,5 +1,5 @@ // CodeQL 2.26.x can analyze Kotlin 2.4.0 through 2.4.19, while the production -// build must use 2.4.20-Beta1 or newer to address GHSA-r937-wjx7-w2jp. +// build uses 2.4.21 (2.4.20 or newer addresses GHSA-r937-wjx7-w2jp). // This init script is used only by the isolated, cache-disabled CodeQL compile. gradle.beforeProject { project -> project.buildscript.configurations.configureEach { configuration -> diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aea2bd7..e6c56c1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -102,7 +102,7 @@ jobs: env: # Keep the scanner-only compatibility compiler isolated from every # persistent Gradle cache. Production builds continue to use the - # patched 2.4.20-Beta1 plugin declared in build.gradle. + # patched 2.4.21 plugin declared in build.gradle. GRADLE_USER_HOME: ${{ runner.temp }}/codeql-gradle-home run: > ./gradlew diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3e3d3..672bfe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,106 @@ All notable changes to mobileGF2logger are documented here. +## 2.5.0 - 2026-10-16 + +### Added + +- Draw every screen edge to edge with system-bar, cutout, and keyboard insets, + and keep drafts, selections, and pending exports across rotation, theme or + language changes, and process recreation. +- Rebuild the weekly table around a frozen member column, synced horizontal day + columns, and a sticky day header; tapping a cell explains every metric of + that day in one sheet. +- Edit weekly cells one day at a time with tri-state Login/Patrol choices, + highlighted changes, an explicit cancel, and a discard confirmation. +- Add Platoon roster filters (Active, Withdrawn, All), a sort chooser, summary + tiles, and long-press selection for CSV export; add move up/down + accessibility actions to member ordering. +- Show capture notices in English and Korean, the install state of both + supported clients, and a live one-time-capture checklist without polling. +- Restore either backup kind from one Settings entry, which detects whether the + archive includes app settings; replay the welcome guide from Settings. +- Localize every label in shared weekly PNGs. +- Open the weekly grid at today's column, add Apply, next day to cell edits, and + show Home's latest roster change with joined/withdrew counts. +- Add a clear button to Platoon search and confirm before a sort replaces a + hand-arranged member order. + +### Changed + +- Move backups into Settings and keep Home focused on capture, the active + Platoon, data tools, and Recent/Saved packet history tabs. +- Move secondary weekly and Platoon utilities into top-bar overflow menus. +- Request the notification permission when capture starts instead of at launch. +- Render table-history previews as the native table instead of a bitmap. +- Split the management database into focused SQL objects behind the same + single transactional owner, and split the capture service into a serial + worker, flow engine, admission coordinator, and notification helper. +- Add AndroidX Activity as the only new dependency. +- Raise secondary-text and status-mark contrast, keep the accent for tappable or + selected items, mark today in the weekly header, fill below-cutline cells, and + keep rank highlights for the top three. +- Show Undo last CSV import only while a checkpoint exists; scale weekly rows + and date fields with the system font size. +- Build with stable Kotlin 2.4.21 instead of 2.4.20-Beta1. + +### Fixed + +- Release native global references, join each capture thread exactly once, and + classify TLS/HTTP flows only on their first payload so plaintext game data + can no longer silently disable inspection. +- Stop capture without blocking the main thread, keep pending Platoon + confirm/discard working after capture stops, and remove cross-thread races in + capture state and status. +- Serialize parsed-packet history per directory, stop deleting files while + listing, and write each roster CSV once with distinct row counts. +- Keep unmergeable continuation payloads instead of silently dropping them. +- Recover CSV checkpoint restores into the Platoon's own settings, release + checkpoint claims on every failure, and remove a lock-order deadlock between + profile deletion recovery and backup restore. +- Compare restored schemas against an in-memory reference, run restore + recovery only when artifacts exist, and roll back partially replaced scoped + settings. +- Derive member active state from one canonical membership-period rule. +- Load member periods in one query, batch inferred boundary correlation, solve + only period-end roster rows, and refresh only weekly revisions a member edit + affects. +- Stop wiping member name and note edits after editing a membership period, + stop deleting a weekly note on tap, make the packet raw/table toggle + reversible, and prevent double restores. +- Move every repository, file, and network call off the main thread. +- Truncate document-provider files when exporting so overwritten files cannot + keep stale trailing bytes. +- Stop a native double free when a closing flow's reset cannot be written, keep + native flow ids unique across captures, clear pending JNI exceptions, and + drive native timers from the monotonic clock. +- Discard a continued roster whose frame is malformed or cut short instead of + publishing it as complete, so missing members are never marked withdrawn. +- Quarantine a bound flow that reports a different, unregistered Platoon, + release a pending claim when confirmation fails, and keep the per-capture + admission limit across service restarts. +- Replay out-of-order captured rosters into history, derive game days from + local 05:00 across daylight-saving changes, export a rejoined member's + current period, and keep the unknown join of a withdrawal-only period + editable. +- Retire CSV import checkpoints atomically, keep weekly notes and overrides + inside their week, render the live weekly table when history recording + fails, and restore the exact timezone when a server change fails. +- Report admission failures inside the New Platoon dialog, block repeated + send/share/backup/restore taps, ask before leaving an edited member, and + dismiss a screen's dialogs when it closes. +- Offer Stop capture in place when a backup, restore, CSV import or profile + change needs capture stopped, and preselect the configured server when a new + Platoon is detected. +- Refresh neighbouring weekly tables that read a capture at the 05:00 reset, + keep restored reset-zone overrides consistent with the Platoon's server, and + roll interrupted restores back to the exact previous settings. +- Keep manual weekly values in restored tables and when saving edits, settle + contradictory Login/Patrol choices, and date manual membership overlaps in + the game time zone. +- Bound manual member names to 256 and notes to 1,000 characters, and close + date pickers and dialogs that belong to a finishing or recreated screen. + ## 2.4.2 - 2026-08-27 ### Added diff --git a/DESIGN.md b/DESIGN.md index 96324c5..e945d7c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,7 +119,7 @@ The palette is warm, low-glare, and predominantly neutral; semantic color is nev ## Layout -Use a 4dp grid, a 16dp default screen inset, and 16-24dp between sections. Every actionable region is at least 48dp high even when its visible fill is inset. Home remains status-led; Settings uses grouped rows; onboarding keeps fixed top controls and bottom navigation around a flexible central explanation. Home, Platoon, and Weekly alone use the three-item bottom navigation. +Use a 4dp grid, a 16dp default screen inset, and 16-24dp between sections. Every actionable region is at least 48dp high even when its visible fill is inset. Content draws edge to edge with system-bar insets applied by the shared screen shell. Home remains status-led; Settings uses grouped rows and owns backup and restore; onboarding keeps fixed top controls and bottom navigation around a flexible central explanation. Home, Platoon, and Weekly alone use the three-item bottom navigation. ## Elevation & Depth @@ -159,7 +159,11 @@ Home, Platoon, and Weekly share a 64dp bottom bar. Each destination has a 24dp i ### Capture Status Panel -Capture state, two compact read-only supported-client package IDs, their concise help popover, and Start/One-time/Stop actions form one operational group. Start actions become unavailable while capture is preparing or active; Stop becomes unavailable while stopped. +Capture state, its localized latest notice, the one-time capture checklist, the two supported clients with their package IDs and install state, and the capture actions form one operational group. While stopped the panel offers One-time capture (primary) and Continuous capture; while preparing or capturing those are replaced by a single Stop action. + +### Weekly Table + +The member column stays frozen while the seven day columns and the total scroll horizontally as one grid under a sticky day header. Each cell shows merit, the Gunsmoke score and attempts when relevant, and Login/Patrol marks; tapping it explains every metric's evidence in one sheet. Edit mode edits one member-day at a time and highlights changes until they are saved. ### Onboarding @@ -170,6 +174,7 @@ Five dark editorial pages present the emblem, one concise topic, progress, three ### Do: - **Do** keep capture state and next actions visible without scrolling on the primary screen. +- **Do** move secondary utilities into the top-bar overflow menu instead of stacking full-width buttons. - **Do** preserve 48dp targets, native checked semantics, and Android Back behavior. - **Do** verify English, Korean, light/dark themes, system bars, and enlarged text on a physical device. - **Do** use orange sparingly for primary action and selection. diff --git a/PRODUCT.md b/PRODUCT.md index 300cda1..9933120 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -33,7 +33,8 @@ recoverable user-controlled backup and export. - Keep the existing Kotlin/Android Views architecture. Do not introduce a parallel UI framework or move repository, parser, capture, or report policy - into Activities. + into Activities. AndroidX Activity is the only UI library dependency; it + provides ComponentActivity, the Activity Result API, and edge-to-edge support. - Preserve English and Korean, System/Light/Dark appearance, Android Back behavior, private app storage, and explicit user-driven share/export. - Keep the three primary destinations clear: Home, Platoon, and Weekly. @@ -57,8 +58,8 @@ is the binding visual direction for the v2.3.0 frontend: stop, destructive, or failed states; - layouts are compact, status-led, flat, and separated with hairlines or tonal surfaces instead of nested oversized cards; -- Home uses a status dashboard, key Platoon/Weekly shortcuts, concise utilities, - recent evidence, and a three-destination bottom bar; +- Home uses a status dashboard, the active Platoon, concise data tools, recent + evidence, and a three-destination bottom bar; - Settings uses a compact top bar and grouped list rows; - onboarding uses an editorial dark composition with the emblem, concise feature guidance, visible progress, top-right Skip, and bottom navigation. diff --git a/README.md b/README.md index 7292e36..41ac6ae 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ never stored. - Decodes each Platoon's banner frame and mark from `21905` and displays the matching original in-game emblem in profile selectors. - Tracks active and withdrawn members, non-overlapping repeat membership periods, exact Updates timestamps, editable nicknames, and private notes. -- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the selected server's daily reset, with cut-off points and manual correction for missing data. +- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the selected server's daily reset, with a frozen member column, a sticky day header, cut-off points, and per-day manual correction for missing data. - Offers One-time Capture that tracks the four useful Platoon payloads and stops automatically when the checklist is complete. - Explains every weekly cell on tap and summarizes missing or uncertain evidence in an Evidence Health panel. - Keeps up to 15 complete automatic revisions per weekly table so an earlier projection and its displayed member context can be previewed and restored after an accidental import. @@ -35,10 +35,11 @@ never stored. - Saves or shares a weekly PNG with opt-in controls for names, UIDs, and private notes. - Can send a validated original CSV to an optional user-owned Discord incoming webhook after confirmation. - Stores the latest 100 parsed packets and up to 50 saved packets, with table and raw views, copy, export, selection, and deletion. -- Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and profile-aware `.gf2backup` export/restore that leaves other Platoons unchanged. +- Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and profile-aware `.gf2backup` export/restore in Settings that detects whether a backup includes app settings and leaves other Platoons unchanged. - Keeps a newly detected Platoon's packets in bounded memory until the user confirms one of the verified client's compatible servers; unconfirmed data is discarded on force-stop or process death. - Provides profile management for correcting server metadata without moving data and for deleting one isolated Platoon behind two confirmations and an exact-name check. -- Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action. +- Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action; the guide can be replayed from Settings. +- Draws edge to edge and keeps drafts, selections, and pending exports across rotation, theme or language changes, and process recreation. - Supports English and Korean, System/Light/Dark themes, and the six known Darkwinter/HaoPlay server-region reset presets converted to the phone timezone. Selecting a detected Platoon automatically follows that profile's saved region. diff --git a/README_KR.md b/README_KR.md index 4f03229..1f7d499 100644 --- a/README_KR.md +++ b/README_KR.md @@ -17,7 +17,7 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - `21905`에서 각 서클의 배너 프레임과 도안을 해석하고, 서클 선택 화면에 대응하는 게임 원본 엠블럼을 표시합니다. - 가입 및 탈퇴 멤버, 서로 겹치지 않는 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다. -- 선택한 서버의 일일 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다. +- 선택한 서버의 일일 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 멤버 열 고정과 날짜 머리글 고정, 커트라인, 날짜별 누락 데이터 수동 수정을 지원합니다. - 한 번만 캡처는 네 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다. - 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다. - 주간 표마다 표시 당시의 멤버 문맥까지 포함한 전체 자동 기록을 최대 15개 보관하여 잘못 가져온 뒤에도 이전 표를 미리 보고 복원할 수 있습니다. @@ -29,10 +29,11 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다. - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. -- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 다른 서클을 변경하지 않는 프로필별 백업 및 복원을 지원합니다. +- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 다른 서클을 변경하지 않는 프로필별 백업 및 복원을 설정에서 지원하며, 복원 시 앱 설정 포함 여부를 자동으로 판별합니다. - 새 서클은 확인된 클라이언트에 맞는 서버를 사용자가 선택할 때까지 패킷을 제한된 메모리에만 보관하며, 강제 종료 또는 프로세스 종료 시 확인되지 않은 데이터를 버립니다. - 서버 정보는 데이터를 이동하지 않고 수정할 수 있으며, 서클 삭제는 두 번의 확인과 정확한 서클 이름 입력을 거쳐 해당 격리 데이터만 제거합니다. -- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. +- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. 안내는 설정에서 다시 볼 수 있습니다. +- 화면을 가장자리까지 그리며, 회전·테마·언어 변경이나 프로세스 재생성 후에도 작성 중인 내용과 선택, 대기 중인 내보내기를 유지합니다. - 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. 알려진 Darkwinter/HaoPlay 6개 서버 지역의 초기화 시각을 기기 시간대로 환산하며, 감지된 서클을 선택하면 해당 프로필에 저장된 지역을 자동으로 따릅니다. - HaoPlay(`com.haoplay.game.and.exilium`)와 Darkwinter(`com.Sunborn.SnqxExilium.Glo`) Android 클라이언트를 별도의 VPN 대상으로 등록합니다. - 다음 열 순서의 UTF-8 서클 멤버 CSV 파일을 만듭니다. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8f3456f..a8a905b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -18,3 +18,9 @@ zdtun is dynamically packaged as `libzdtun.so`; mobileGF2logger's JNI integratio - License declaration: MIT mobileGF2logger reproduces the documented framing, recognized payload identifiers, protobuf field mappings, and Platoon CSV column order. It does not bundle the Python application or generated Python protobuf runtime. + +## Material icons + +- Project: [google/material-design-icons](https://github.com/google/material-design-icons) +- License: Apache License 2.0 +- Bundled source: vector paths in `app/src/main/res/drawable/ic_*.xml` diff --git a/app/build.gradle b/app/build.gradle index dde734e..9a77e2d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20402 - versionName = '2.4.2' + versionCode = 20500 + versionName = '2.5.0' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { @@ -223,6 +223,7 @@ android { dependencies { implementation project(':protocol') + implementation 'androidx.activity:activity:1.12.4' implementation 'androidx.core:core:1.17.0' implementation 'androidx.recyclerview:recyclerview:1.4.0' testImplementation 'junit:junit:4.13.2' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 020cbf8..9580f97 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,7 +2,8 @@ -keep class dev.gf2log.app.capture.NativeCaptureBridge { *; } -keep interface dev.gf2log.app.capture.NativeCaptureBridge$PayloadListener { *; } -keepclassmembers class * implements dev.gf2log.app.capture.NativeCaptureBridge$PayloadListener { - public void onPayload(long, boolean, byte[]); + public void onFlowOpened(long, int, java.lang.String, int, java.lang.String, int); + public void onPayload(long, byte[]); public void onFlowClosed(long); public void onTraffic(long, long, long); public void onCaptureStopped(); diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt index 7d604b0..6551579 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -9,7 +9,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import dev.gf2log.app.TargetPackagePreferences import dev.gf2log.app.SupportedGamePackages -import dev.gf2log.app.WeeklyPngPendingState +import dev.gf2log.app.WeeklyPngExport import dev.gf2log.app.WeeklyReportActivity import dev.gf2log.app.settings.AppBackupSettings import dev.gf2log.app.settings.AppBackupSettingsCodec @@ -17,6 +17,7 @@ import dev.gf2log.app.settings.AppSettingsStore import dev.gf2log.app.settings.BackupSettingsStore import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.ScopedAppSettingsStore import dev.gf2log.app.settings.WeeklyCutlines import dev.gf2log.protocol.GuildMembersCsv @@ -160,8 +161,10 @@ class PlatoonBackupManagerIntegrationTest { writeRetainedCsv(CURRENT_UID, "Cached current member") PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + // The stored value is untouched; settings reads project an unusable one to the default. + assertEquals(INVALID_TARGET_PACKAGE, TargetPackagePreferences.get(context)) assertEquals( - currentSettings().copy(targetPackage = INVALID_TARGET_PACKAGE), + currentSettings().copy(targetPackage = TargetPackagePreferences.DEFAULT_TARGET_PACKAGE), settingsStore.read(), ) assertFalse(FilePaths.retainedCsvDirectory(context).exists()) @@ -254,6 +257,24 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) } + @Test + fun rollbackInterruptedWhileCleaningUpFinishesOnTheNextStart() { + settingsStore.replace(currentSettings()) + // Older cleanup deleted the settings snapshot before the state, so an interruption there + // left a PREPARED state with only the rollback marker of an already completed rollback. + FilePaths.restoreStateFile(context).apply { + parentFile?.mkdirs() + writeText("PREPARED", Charsets.US_ASCII) + } + java.io.File(FilePaths.restoreTransactionDirectory(context), "settings.rollback_required") + .writeBytes(ByteArray(0)) + + PlatoonBackupManager.recoverInterruptedFullRestore(context) + + assertEquals(currentSettings(), settingsStore.read()) + assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) + } + @Test fun settingsFailureRollsBackDatabaseAndSettingsTogether() { seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") @@ -479,6 +500,104 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(CsvImportCheckpointManager(context, storageScope).canUndo()) } + @Test + fun constructingRepositoriesAndManagersDoesNotWaitForTheExclusiveGate() { + val held = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + val holder = Thread { + PlatoonRepository.withExclusiveDatabase(storageScope) { + held.countDown() + release.await() + } + }.apply { + isDaemon = true + start() + } + held.await() + val executor = java.util.concurrent.Executors.newSingleThreadExecutor() + try { + executor.submit { + PlatoonRepository(context, storageScope) + PlatoonBackupManager(context, storageScope) + }.get(5, java.util.concurrent.TimeUnit.SECONDS) + } finally { + release.countDown() + holder.join() + executor.shutdown() + } + } + + @Test + fun csvCheckpointRecoversAnInterruptedRestoreIntoTheScopedSettings() { + settingsStore.replace(currentSettings()) + FilePaths.restoreSettingsFile(context).apply { + parentFile?.mkdirs() + writeBytes(AppBackupSettingsCodec.encode(archivedSettings())) + } + FilePaths.restoreStateFile(context).writeText("PREPARED", Charsets.US_ASCII) + + CsvImportCheckpointManager(context, storageScope).apply { + create(emptySet()) + seal() + } + + assertEquals(archivedSettings(), settingsStore.read()) + assertTrue(dev.gf2log.app.settings.UserSettingsPreferences.memberOrder(context).isEmpty()) + assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) + } + + @Test + fun csvImportClaimsAreScopedAndReleasedAfterAFailedImport() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + val otherScope = PlatoonStorageScope( + PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + dev.gf2log.protocol.model.PlatoonProfileData(202u, "Other Platoon", 0u, 0u), + ).storageId, + ) + val pending = CsvImportCheckpointManager(context, storageScope) + pending.create(emptySet()) + + CsvImportCheckpointManager(context, otherScope).runImport(emptySet()) {} + pending.seal() + + assertThrows(IllegalStateException::class.java) { + CsvImportCheckpointManager(context, storageScope).runImport(emptySet()) { + error("simulated import failure") + } + } + val retried = CsvImportCheckpointManager(context, storageScope) + retried.runImport(emptySet()) {} + assertTrue(retried.canUndo()) + } + + @Test + fun aFailedSealKeepsTheImportClaimUntilItsRollbackFinishes() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + var undoAvailableDuringRollback: Boolean? = null + lateinit var manager: CsvImportCheckpointManager + manager = CsvImportCheckpointManager(context, storageScope) { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + undoAvailableDuringRollback = manager.canUndo() + } + } + + assertThrows(IllegalStateException::class.java) { + manager.runImport(emptySet()) { + // A digest that is already present makes seal fail after the import applied. + java.io.File(FilePaths.csvCheckpointDirectory(context), "post-import.sha256") + .writeText("0".repeat(64), Charsets.US_ASCII) + } + } + + // Another operation sees the claim for the whole rollback, then can take it again. + assertEquals(false, undoAvailableDuringRollback) + val retried = CsvImportCheckpointManager(context, storageScope) + retried.runImport(emptySet()) {} + assertTrue(retried.canUndo()) + } + @Test fun interruptedCsvUndoCompletesAfterDatabaseInstallationOnRestart() { seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") @@ -509,6 +628,32 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) } + @Test + fun interruptedCheckpointRetirementNeverBlocksLaterImports() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + CsvImportCheckpointManager(context, storageScope).runImport(emptySet()) { + replaceDatabaseForCheckpoint(CURRENT_UID, "After import", "after-import.csv") + } + // A process death while deleting a consumed checkpoint leaves only part of its + // retired copy; it must never be mistaken for, or block, a live checkpoint. + val retired = FilePaths.csvCheckpointDiscardedDirectory(context).apply { mkdirs() } + java.io.File(retired, "planned-files.txt").writeText("\n", Charsets.UTF_8) + + val recovered = CsvImportCheckpointManager(context, storageScope) + + assertTrue(recovered.canUndo()) + assertFalse(retired.exists()) + recovered.restore() + assertFalse(FilePaths.csvCheckpointDirectory(context).exists()) + assertFalse(FilePaths.csvCheckpointDiscardedDirectory(context).exists()) + PlatoonDatabase(context).use { database -> + assertEquals(1L, count(database.readableDatabase, "members", "uid = ?", ARCHIVED_UID)) + } + val next = CsvImportCheckpointManager(context, storageScope) + next.runImport(emptySet()) {} + assertTrue(next.canUndo()) + } + @Test fun unfinishedCsvImportRecoversBeforePreviewRepositoryReads() { seedDatabase(ARCHIVED_UID, "Before interrupted import", "before-interrupted-import.csv") @@ -645,6 +790,113 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(context.getDatabasePath(restoredScope.databaseName).exists()) } + @Test + fun completeRestoreOfAnAutomaticResetRegionKeepsItFollowingTheProfile() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).exportFull(it) + }.toByteArray() + + PlatoonBackupManager(context).restoreFull(ByteArrayInputStream(archive)) + + // The archive records only the effective region, which equals the profile region, so + // no explicit override is fabricated that a later server change would leave stale. + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + PlatoonProfileAdministration(context) { _, _, _ -> } + .changeServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_GLOBAL) + assertTimeZoneFollows(GameServerRegion.HAOPLAY_GLOBAL) + } + + @Test + fun platoonOnlyRestoreThatChangesTheServerRegionClearsTheStaleOverride() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val registry = PlatoonProfileRegistry(context) + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_GLOBAL) + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).export(it) + }.toByteArray() + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_KOREA) + // An override for the previous region, as an earlier complete restore wrote it. + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_KOREA, storageScope.storageId) + + PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + + assertEquals( + GameServerRegion.HAOPLAY_GLOBAL, + registry.find(storageScope.storageId)?.serverRegion, + ) + assertTimeZoneFollows(GameServerRegion.HAOPLAY_GLOBAL) + } + + @Test + fun platoonOnlyRestoreThatKeepsTheServerRegionKeepsAnExplicitOverride() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).export(it) + }.toByteArray() + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_GLOBAL, storageScope.storageId) + + PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + + assertFalse(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + assertEquals( + GameServerRegion.HAOPLAY_GLOBAL, + GameTimeZonePreferences.region(context, storageScope.storageId), + ) + } + + @Test + fun interruptedRestoresPutTheExactTimeZoneKeysBack() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val registry = PlatoonProfileRegistry(context) + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_GLOBAL) + val platoonArchive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).export(it) + }.toByteArray() + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_KOREA) + val completeArchive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).exportFull(it) + }.toByteArray() + val dying = PlatoonBackupManager( + context = context, + settingsStore = settingsStore, + restoreObserver = { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.PROFILE_METADATA_INSTALLED) { + throw SimulatedProcessDeath() + } + }, + storageScope = storageScope, + ) + + // An automatic region survives an interrupted complete restore as automatic. + val automatic = GameTimeZonePreferences.scopedState(context, storageScope.storageId) + assertThrows(SimulatedProcessDeath::class.java) { + dying.restoreFull(ByteArrayInputStream(completeArchive)) + } + PlatoonBackupManager.recoverInterruptedFullRestore(context) + assertEquals(automatic, GameTimeZonePreferences.scopedState(context, storageScope.storageId)) + + // An explicit override cleared by a region-changing restore comes back on rollback. + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_JAPAN, storageScope.storageId) + val explicit = GameTimeZonePreferences.scopedState(context, storageScope.storageId) + assertThrows(SimulatedProcessDeath::class.java) { + dying.restore(ByteArrayInputStream(platoonArchive)) + } + PlatoonBackupManager.recoverInterruptedFullRestore(context) + assertEquals(explicit, GameTimeZonePreferences.scopedState(context, storageScope.storageId)) + assertEquals( + GameServerRegion.HAOPLAY_KOREA, + registry.find(storageScope.storageId)?.serverRegion, + ) + } + + private fun assertTimeZoneFollows(region: GameServerRegion) { + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + assertEquals(region, GameTimeZonePreferences.region(context, storageScope.storageId)) + assertEquals(region.serverZone, GameTimeZonePreferences.get(context, storageScope.storageId)) + } + private fun replaceDatabaseWithCurrentState() { replaceDatabaseForCheckpoint(CURRENT_UID, "Current member", "current-source.csv") } @@ -917,6 +1169,9 @@ class PlatoonBackupManagerIntegrationTest { fun csvCheckpointPreviousDirectory(context: Context) = java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint.previous") + + fun csvCheckpointDiscardedDirectory(context: Context) = + java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint.discarded") } private class SimulatedProcessDeath : Error() @@ -979,37 +1234,20 @@ class WeeklyReportActivityStateTest { closingBoundaries = 0, ), ) - val writeMethod = WeeklyReportActivity::class.java.getDeclaredMethod( - "writeWeeklyPng", - WeeklyShareProjection.Document::class.java, - LocalDate::class.java, - ).apply { isAccessible = true } try { - ActivityScenario.launch(WeeklyReportActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - val first = writeMethod.invoke(activity, document, periodStart) as java.io.File - val firstUri = FileProvider.getUriForFile( - activity, - activity.packageName + ".fileprovider", - first, - ) - val second = writeMethod.invoke(activity, document, periodStart) as java.io.File - val secondUri = FileProvider.getUriForFile( - activity, - activity.packageName + ".fileprovider", - second, - ) - - assertFalse(first.exists()) - assertNotEquals(first.canonicalPath, second.canonicalPath) - assertNotEquals(firstUri, secondUri) - activity.contentResolver.openInputStream(secondUri).use { input -> - assertTrue(input != null && input.read() >= 0) - } - } + val first = WeeklyPngExport.render(context, document, periodStart) + val firstUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", first) + val second = WeeklyPngExport.render(context, document, periodStart) + val secondUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", second) + + assertFalse(first.exists()) + assertNotEquals(first.canonicalPath, second.canonicalPath) + assertNotEquals(firstUri, secondUri) + context.contentResolver.openInputStream(secondUri).use { input -> + assertTrue(input != null && input.read() >= 0) } } finally { - WeeklyPngPendingState.directory(context.cacheDir) + WeeklyPngExport.directory(context.cacheDir) .listFiles() .orEmpty() .forEach(java.io.File::delete) @@ -1018,19 +1256,21 @@ class WeeklyReportActivityStateTest { @Test fun pendingWeeklyPngSurvivesActivityRecreation() { - WeeklyPngPendingState.directory(context.cacheDir).mkdirs() - val target = WeeklyPngPendingState + WeeklyPngExport.directory(context.cacheDir).mkdirs() + val target = WeeklyPngExport .newRenderTarget(context.cacheDir, LocalDate.of(2026, 8, 9)) .apply { writeBytes(byteArrayOf(1, 2, 3)) } - val field = WeeklyReportActivity::class.java.getDeclaredField("pendingPng").apply { + // The pending render lives in the screen's export flow. + val exports = WeeklyReportActivity::class.java.getDeclaredField("exports").apply { isAccessible = true } + val pending = dev.gf2log.app.WeeklyExportFlow::class.java.getDeclaredField("pendingPng").apply { isAccessible = true } try { ActivityScenario.launch(WeeklyReportActivity::class.java).use { scenario -> - scenario.onActivity { activity -> field.set(activity, target) } + scenario.onActivity { activity -> pending.set(exports.get(activity), target) } scenario.recreate() scenario.onActivity { activity -> - assertEquals(target.canonicalFile, (field.get(activity) as java.io.File).canonicalFile) + assertEquals(target.canonicalFile, (pending.get(exports.get(activity)) as java.io.File).canonicalFile) } } } finally { diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt index 4942a13..018a77b 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -446,6 +446,71 @@ class PlatoonDatabaseIntegrationTest { assertForeignKeysValid() } + @Test + fun rejectedNestedWritesDoNotDiscardTheOuterTransaction() { + val snapshot = PlatoonSnapshot( + id = 0, + capturedAt = Instant.parse("2026-07-31T00:00:00Z"), + sourceFile = "committed.csv", + members = listOf(member(TARGET_UID, "Committed")), + ) + val joined = MembershipBoundaryValue( + date = LocalDate.of(2026, 7, 1), + instant = Instant.parse("2026-07-01T00:00:00Z"), + timeKnown = true, + ) + + database.runInTransaction { + database.ingestSnapshot(snapshot, EvidenceSource.LEGACY_IMPORT) + assertTrue(database.ingestSnapshot(snapshot, EvidenceSource.LEGACY_IMPORT).duplicate) + assertFalse(database.addWithdrawnMember(TARGET_UID, "Again", joined, joined, "")) + assertFalse(database.addMembershipPeriod(OTHER_UID, joined, null, "")) + assertFalse(database.deleteMember(OTHER_UID)) + assertFalse(database.deleteMembershipPeriod(Long.MAX_VALUE)) + assertFalse(database.updateMembershipPeriod(Long.MAX_VALUE, joined, null, "")) + } + + assertEquals(1L, count("snapshots", "source_file = ?", "committed.csv")) + assertEquals(1L, count("members", "uid = ?", TARGET_UID)) + assertForeignKeysValid() + } + + @Test + fun anOutOfOrderCapturedRosterIsReplayedWithoutWithdrawingCurrentMembers() { + val newer = Instant.parse("2026-08-02T00:00:00Z") + val older = Instant.parse("2026-08-01T00:00:00Z") + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = newer, + sourceFile = "newer.csv", + members = listOf(member(TARGET_UID, "Target"), member(OTHER_UID, "Other")), + ), + EvidenceSource.SNAPSHOT, + ) + + val late = database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = older, + sourceFile = "older.csv", + members = listOf(member(TARGET_UID, "Target")), + ), + EvidenceSource.SNAPSHOT, + ) + + assertTrue(late.historical) + assertEquals(0, late.left) + val statuses = database.listMemberStatuses().associateBy(MemberStatus::uid) + assertTrue(statuses.getValue(TARGET_UID).isActive) + assertTrue(statuses.getValue(OTHER_UID).isActive) + val otherPeriod = statuses.getValue(OTHER_UID).membershipPeriods.single() + assertEquals(newer, otherPeriod.joinedAt) + assertEquals(null, otherPeriod.leftAt) + assertEquals(2L, count("snapshots", "id > ?", 0)) + assertForeignKeysValid() + } + @Test fun databaseRejectsRosterBoundsForEveryIngestionCaller() { val oversizedRoster = (1..257).map { index -> member(index.toLong(), "Member $index") } @@ -568,6 +633,49 @@ class PlatoonDatabaseIntegrationTest { } } + @Test + fun everyWriterAgreesWithTheCanonicalActiveStateRule() { + val rosterAt = Instant.parse("2026-07-31T00:00:00Z") + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = rosterAt, + sourceFile = "current.csv", + members = listOf(member(OTHER_UID, "Current")), + ), + EvidenceSource.SNAPSHOT, + ) + database.ingestPlatoonUpdates( + listOf( + update( + PlatoonUpdateSemantics.KIND_JOIN, + rosterAt.minusSeconds(3_600), + TARGET_UID, + "Backfilled", + ), + ), + rosterAt.plusSeconds(60), + ) + assertFalse(database.listMemberStatuses().single { it.uid == TARGET_UID }.isActive) + + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = rosterAt.minusSeconds(7_200), + sourceFile = "older.csv", + members = listOf(member(OTHER_UID, "Current")), + ), + EvidenceSource.LEGACY_IMPORT, + historicalOnly = true, + ) + val written = database.listMemberStatuses().associate { it.uid to it.isActive } + assertEquals(mapOf(OTHER_UID to true, TARGET_UID to false), written) + + MembershipRows.synchronizeAllActiveStates(database.writableDatabase) + + assertEquals(written, database.listMemberStatuses().associate { it.uid to it.isActive }) + } + @Test fun schemaTwelveUpgradeRepairsDerivedActiveState() { val databaseName = "platoon-v12-membership-state-test.db" @@ -1211,6 +1319,74 @@ class PlatoonDatabaseIntegrationTest { ) } + @Test + fun weeklyAnnotationsOutsideTheirWeekAreRejectedBeforePersistence() { + val periodStart = LocalDate.of(2026, 8, 16) + val override = WeeklyCellOverride( + uid = TARGET_UID, + periodStart = periodStart, + gameDay = periodStart, + meritDelta = 50, + scoreDelta = null, + attempts = null, + attended = true, + dailyPatrol = null, + ) + + assertThrows(IllegalArgumentException::class.java) { + database.addWeeklyNote(periodStart.toEpochDay(), periodStart.plusDays(7).toEpochDay(), "Late") + } + assertThrows(IllegalArgumentException::class.java) { + database.replaceWeeklyOverrides( + periodStart.toEpochDay(), + listOf(override.copy(gameDay = periodStart.minusDays(1))), + ) + } + assertThrows(IllegalArgumentException::class.java) { + database.replaceWeeklyOverrides(periodStart.toEpochDay(), listOf(override, override)) + } + database.replaceWeeklyOverrides(periodStart.toEpochDay(), listOf(override)) + + assertTrue(database.listWeeklyNotes(periodStart.toEpochDay()).isEmpty()) + assertEquals(listOf(override), database.listWeeklyOverrides(periodStart.toEpochDay())) + } + + @Test + fun unknownJoinOfAnUpdatesWithdrawalPeriodStaysEditable() { + val withdrewAt = Instant.parse("2026-08-10T12:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_WITHDRAW, withdrewAt, TARGET_UID, "Leaver")), + withdrewAt.plusSeconds(60), + ) + val period = database.listMemberStatuses().single { it.uid == TARGET_UID } + .membershipPeriods.single() + assertEquals(null, period.joinedAt) + assertEquals(EvidenceSource.GAME_UPDATES, period.joinedSource) + assertTrue(period.isJoinedBoundaryEditable) + assertFalse(period.isLeftBoundaryEditable) + val joined = MembershipBoundaryValue( + date = LocalDate.of(2026, 8, 1), + instant = Instant.parse("2026-08-01T09:00:00Z"), + timeKnown = true, + ) + val attemptedLeft = MembershipBoundaryValue( + date = LocalDate.of(2026, 8, 20), + instant = Instant.parse("2026-08-20T09:00:00Z"), + timeKnown = true, + ) + + assertTrue(database.updateMembershipPeriod(period.id, joined, attemptedLeft, "Recovered")) + + val updated = database.listMemberStatuses().single { it.uid == TARGET_UID } + .membershipPeriods.single() + assertEquals(joined.instant, updated.joinedAt) + assertEquals(EvidenceSource.MANUAL, updated.joinedSource) + assertEquals(withdrewAt, updated.leftAt) + assertEquals(EvidenceSource.GAME_UPDATES, updated.leftSource) + assertEquals("Recovered", updated.note) + assertForeignKeysValid() + } + private fun update(kind: Long, at: Instant, uid: Long, name: String) = PlatoonUpdateObservation( kind = kind, diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 5ead2b9..4340c34 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -5,6 +5,7 @@ import dev.gf2log.app.ActivePlatoonScopeBinding import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines @@ -390,6 +391,36 @@ class PlatoonProfileRegistryIntegrationTest { ) } + @Test + fun failedServerRegionChangeRestoresAManualTimezoneExactly() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101818u, "Manual zone", 0u, 0u), + ) + // A schema-v1-v3 complete backup restores a manual timezone for its Platoon. + val manualZone = java.time.ZoneId.of("America/New_York") + GameTimeZonePreferences.set(context, manualZone, profile.storageId) + var rebuilds = 0 + val administration = PlatoonProfileAdministration(context) { _, _, _ -> + rebuilds += 1 + check(rebuilds > 1) { "Simulated weekly-history rebuild failure" } + } + + assertThrows(IllegalStateException::class.java) { + administration.changeServerRegion(profile.storageId, GameServerRegion.HAOPLAY_JAPAN) + } + + assertEquals(2, rebuilds) + assertEquals(GameServerRegion.HAOPLAY_KOREA, registry.find(profile.storageId)?.serverRegion) + assertEquals( + GameServerRegion.MANUAL, + GameTimeZonePreferences.region(context, profile.storageId), + ) + assertEquals(manualZone, GameTimeZonePreferences.get(context, profile.storageId)) + } + @Test fun restoreCannotRegisterOneFullIdentityUnderTwoStorageScopes() { val registry = PlatoonProfileRegistry(context) @@ -452,6 +483,89 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals(null, WeeklyCutlinePreferences(context, deleted.storageId).read().dailyMerit) } + @Test + fun memberEditsRefreshOnlyTheWeeksThatShowTheMember() { + val profile = PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(5u, "Weeks", 0u, 0u), + ) + val scope = PlatoonStorageScope(profile.storageId) + val repository = PlatoonRepository(context, scope) + val firstCapture = Instant.parse("2026-08-04T12:00:00Z") + repository.ingest(firstCapture, listOf(member(1u, "Stayer")), "first.csv") + repository.ingest( + firstCapture.plusSeconds(7L * 24L * 60L * 60L), + listOf(member(1u, "Stayer"), member(2u, "Newcomer")), + "second.csv", + ) + val zone = dev.gf2log.app.settings.GameTimeZonePreferences.get(context, profile.storageId) + val firstWeek = PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(firstCapture, zone)) + val secondWeek = firstWeek.plusDays(7) + // A stale latest revision makes any rebuild of the first week observable. + PlatoonRepository.withExclusiveDatabase(scope) { + PlatoonDatabase(context, scope.databaseName).use { database -> + database.recordWeeklyReportHistory( + periodStartEpochDay = firstWeek.toEpochDay(), + recordedAt = Instant.now(), + fingerprint = "0".repeat(64), + payload = byteArrayOf(1), + clearActiveOnChange = false, + ) + } + } + val firstWeekRevisions = repository.listWeeklyReportHistory(firstWeek).size + val secondWeekRevisions = repository.listWeeklyReportHistory(secondWeek).size + + assertTrue(repository.updateMember(2L, "Renamed", "")) + + assertEquals(firstWeekRevisions, repository.listWeeklyReportHistory(firstWeek).size) + assertEquals(secondWeekRevisions + 1, repository.listWeeklyReportHistory(secondWeek).size) + assertEquals( + "Renamed", + repository.buildWeeklyTableRevision(secondWeek, zone).memberNamesByUid[2L], + ) + } + + @Test + fun pendingDeletionRecoveryCannotDeadlockWithAnExclusiveRestore() { + val registry = PlatoonProfileRegistry(context) + val kept = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(1u, "Kept", 0u, 0u), + ) + val pendingId = PlatoonProfileIdentity.randomStorageId() + context.getSharedPreferences("platoon_profile_deletions", android.content.Context.MODE_PRIVATE) + .edit().putStringSet("pending_storage_ids", setOf(pendingId)).commit() + val exclusiveHeld = java.util.concurrent.CountDownLatch(1) + val failures = java.util.concurrent.ConcurrentLinkedQueue() + val restore = Thread { + runCatching { + PlatoonRepository.withExclusiveDatabase(PlatoonStorageScope(kept.storageId)) { + exclusiveHeld.countDown() + Thread.sleep(300) + registry.find(kept.storageId) + } + }.onFailure(failures::add) + }.apply { isDaemon = true } + val reader = Thread { + runCatching { + exclusiveHeld.await() + registry.list() + }.onFailure(failures::add) + }.apply { isDaemon = true } + + restore.start() + reader.start() + restore.join(10_000) + reader.join(10_000) + + assertFalse("registry and maintenance locks deadlocked", restore.isAlive || reader.isAlive) + assertTrue(failures.isEmpty()) + assertEquals(listOf(kept.storageId), registry.list().map(PlatoonProfile::storageId)) + } + @Test fun registryRejectsUnboundedNewProfilesAndCanRemoveInactiveMetadata() { val registry = PlatoonProfileRegistry(context) @@ -494,6 +608,38 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals(999L, recovered.platoonId) } + @Test + fun autoDetectedRestoreOfAPlatoonOnlyBackupKeepsAppSettings() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_JAPAN, + PlatoonProfileData(101818u, "Larks", 0u, 0u), + ) + registry.setActive(profile.storageId) + val scope = PlatoonStorageScope(profile.storageId) + PlatoonRepository(context, scope).ingest( + Instant.parse("2026-08-24T02:00:00Z"), + listOf(member(11u, "Archived member")), + "archived.csv", + ) + val archive = ByteArrayOutputStream().also { output -> + PlatoonBackupManager(context, scope).export(output) + }.toByteArray() + MemberOrderPreferences(context, profile.storageId).write(listOf(11L, 12L)) + + assertEquals( + RestoredBackupKind.PLATOON, + PlatoonBackupManager.restoreSelected(context, ByteArrayInputStream(archive)), + ) + + assertEquals(listOf(11L, 12L), MemberOrderPreferences(context, profile.storageId).read()) + assertEquals( + "Archived member", + PlatoonRepository(context, scope).listMemberStatuses().single().name, + ) + } + @Test fun scopedCompleteBackupRecreatesItsProfileOnAFreshRegistry() { val registry = PlatoonProfileRegistry(context) @@ -521,10 +667,9 @@ class PlatoonProfileRegistryIntegrationTest { .edit().clear().commit() File(context.filesDir, "platoons").deleteRecursively() - PlatoonBackupManager.restoreSelected( - context, - ByteArrayInputStream(archive), - complete = true, + assertEquals( + RestoredBackupKind.COMPLETE, + PlatoonBackupManager.restoreSelected(context, ByteArrayInputStream(archive)), ) val restoredRegistry = PlatoonProfileRegistry(context) diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt new file mode 100644 index 0000000..793b081 --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt @@ -0,0 +1,233 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.PlatoonActivityData +import dev.gf2log.protocol.model.PlatoonActivityEntry +import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlatoonRepositoryIntegrationTest { + private val context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() = clearState() + + @After + fun tearDown() = clearState() + + @Test + fun weeklyScreenStillRendersARevisionThatHistoryCannotEncode() { + val repository = PlatoonRepository(context, SCOPE) + val capturedAt = Instant.parse("2026-08-03T12:00:00Z") + repository.ingest(capturedAt, listOf(member(UID, "Short")), "roster.csv") + // A pre-bound Updates row can carry a name longer than the history codec accepts. + val oversizedName = "x".repeat(WeeklyReportHistoryCodec.MAX_STRING_BYTES + 1) + PlatoonRepository.withExclusiveDatabase(SCOPE) { + PlatoonDatabase(context, SCOPE.databaseName).use { database -> + database.writableDatabase.insertOrThrow( + "member_events", + null, + ContentValues().apply { + put("uid", UID) + put("event_type", MemberEventType.REJOINED.name) + put("occurred_at", capturedAt.plusSeconds(60).toEpochMilli()) + put("observed_at", capturedAt.plusSeconds(60).toEpochMilli()) + put("precision", EvidencePrecision.EXACT.name) + put("source", EvidenceSource.GAME_UPDATES.name) + put("note", oversizedName) + }, + ) + } + } + + val revision = repository.buildWeeklyTableRevision( + referenceDay = LocalDate.of(2026, 8, 3), + zoneId = ZoneOffset.UTC, + asOf = capturedAt.plusSeconds(3_600), + ) + + assertEquals(oversizedName, revision.report.members.single().name) + } + + @Test + fun rosterCapturedExactlyAtTheSundayResetAlsoRefreshesTheWeekItCloses() { + val repository = utcRepository() + repository.ingest(Instant.parse("2026-07-29T12:00:00Z"), listOf(member(UID, "A", 100u)), "a.csv") + repository.ingest(Instant.parse("2026-08-01T04:56:00Z"), listOf(member(UID, "A", 200u)), "b.csv") + val before = latestStoredRevision(repository, CLOSING_WEEK) + + // 05:00 on Sunday opens the next game week but is the closing capture of Saturday. + repository.ingest(Instant.parse("2026-08-02T05:00:00Z"), listOf(member(UID, "A", 300u)), "c.csv") + + val after = latestStoredRevision(repository, CLOSING_WEEK) + assertNotEquals(before, after) + assertEquals(liveReport(repository, CLOSING_WEEK), after) + } + + @Test + fun rosterCapturedJustBeforeTheResetRefreshesTheFollowingWeekThatCarriesItIn() { + val repository = utcRepository() + repository.ingest( + Instant.parse("2026-07-29T12:00:00Z"), + listOf(member(UID, "A", 100u), member(OTHER_UID, "B", 100u)), + "a.csv", + ) + // The following week already has evidence of its own, so its revision is listed. + repository.addWeeklyNote(OPENING_WEEK.toEpochDay(), OPENING_WEEK.plusDays(1).toEpochDay(), "Note") + val before = latestStoredRevision(repository, OPENING_WEEK) + assertEquals(setOf(UID, OTHER_UID), before?.members?.map { it.uid }?.toSet()) + + // 04:55 belongs to Saturday's game day, yet this roster is the opening baseline and the + // period-end roster of the following week, which no longer contains the withdrawn B. + repository.ingest(Instant.parse("2026-08-02T04:55:00Z"), listOf(member(UID, "A", 300u)), "b.csv") + + val after = latestStoredRevision(repository, OPENING_WEEK) + assertEquals(listOf(UID), after?.members?.map { it.uid }) + assertEquals(liveReport(repository, OPENING_WEEK), after) + } + + @Test + fun aRosterJoinCorrelatedIntoAnEarlierWeekRefreshesThatWeek() { + val repository = utcRepository() + repository.ingest(Instant.parse("2026-07-29T12:00:00Z"), listOf(member(UID, "A")), "a.csv") + // B's first activity on Saturday evening is the only candidate for B's join time. + repository.ingestActivity( + PlatoonActivityData( + summaries = emptyList(), + entries = listOf( + PlatoonActivityEntry( + kind = 1u, + occurredAt = Instant.parse("2026-08-01T23:00:00Z").epochSecond.toUInt(), + actionId = 1u, + memberName = "B", + ), + ), + ), + capturedAt = Instant.parse("2026-08-01T23:05:00Z"), + ) + assertEquals(listOf(UID), latestStoredRevision(repository, CLOSING_WEEK)?.members?.map { it.uid }) + + // Monday's roster shows B; the exact join it correlates lies in the closing week. + repository.ingest( + Instant.parse("2026-08-03T12:00:00Z"), + listOf(member(UID, "A"), member(OTHER_UID, "B")), + "b.csv", + ) + + val after = latestStoredRevision(repository, CLOSING_WEEK) + assertEquals(setOf(UID, OTHER_UID), after?.members?.map { it.uid }?.toSet()) + assertEquals(liveReport(repository, CLOSING_WEEK), after) + } + + @Test + fun manualTextBeyondItsLimitIsRejectedWithoutChangingStoredRows() { + val repository = utcRepository() + val capturedAt = Instant.parse("2026-08-03T12:00:00Z") + repository.ingest(capturedAt, listOf(member(UID, "A")), "a.csv") + val maximumName = "n".repeat(ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + val maximumNote = "x".repeat(ManualTextPolicy.MAX_NOTE_CHARS) + // Limits apply to the stored, trimmed text. + assertTrue(repository.updateMember(UID, " $maximumName ", "\n$maximumNote\n")) + val stored = status(repository, UID) + assertEquals(maximumName, stored.name) + assertEquals(maximumNote, stored.note) + + val longName = maximumName + "n" + val longNote = maximumNote + "x" + val period = stored.membershipPeriods.single() + val joined = MembershipBoundaryValue( + LocalDate.of(2026, 7, 1), + Instant.parse("2026-07-01T12:00:00Z"), + timeKnown = true, + ) + val withdrew = MembershipBoundaryValue( + LocalDate.of(2026, 7, 2), + Instant.parse("2026-07-02T12:00:00Z"), + timeKnown = true, + ) + listOf( + { repository.updateMember(UID, longName, "") }, + { repository.updateMember(UID, "Short", longNote) }, + { repository.updateMembershipPeriod(period.id, joined, null, longNote) }, + { repository.addMembershipPeriod(UID, joined, withdrew, longNote) }, + { repository.addWithdrawnMember(OTHER_UID, longName, joined, withdrew, "") }, + { repository.addWithdrawnMember(OTHER_UID, "Other", joined, withdrew, longNote) }, + { + repository.addWeeklyNote(OPENING_WEEK.toEpochDay(), OPENING_WEEK.toEpochDay(), longNote) + true + }, + ).forEach { edit -> + assertThrows(IllegalArgumentException::class.java) { edit() } + } + + assertEquals(stored, status(repository, UID)) + assertEquals(listOf(UID), repository.listMemberStatuses().map { it.uid }) + assertTrue(repository.listWeeklyNotes(OPENING_WEEK.toEpochDay()).isEmpty()) + } + + private fun status(repository: PlatoonRepository, uid: Long) = + repository.listMemberStatuses().single { it.uid == uid } + + private fun utcRepository(): PlatoonRepository { + GameTimeZonePreferences.set(context, ZoneOffset.UTC, SCOPE.storageId) + return PlatoonRepository(context, SCOPE) + } + + private fun latestStoredRevision( + repository: PlatoonRepository, + periodStart: LocalDate, + ): WeeklyReportBuilder.Report? = repository.listWeeklyReportHistory(periodStart) + .firstOrNull() + ?.let { repository.weeklyReportHistory(it.id, periodStart) } + + /** The live revision as history stores it, so codec normalization does not differ. */ + private fun liveReport(repository: PlatoonRepository, periodStart: LocalDate) = + WeeklyReportHistoryCodec.decode( + WeeklyReportHistoryCodec.encode( + repository.readWeeklyTableRevision(periodStart, ZoneOffset.UTC), + ).payload, + ) + + private fun member(uid: Long, name: String, totalMerit: UInt = 0u) = GuildMember( + uid = uid.toUInt(), + name = name, + level = 1u, + weeklyMerit = 0u, + totalMerit = totalMerit, + highScore = 0u, + totalScore = 0u, + lastLogin = 0u, + ) + + private fun clearState() { + PlatoonRepository.withExclusiveDatabase(SCOPE) { + context.deleteDatabase(SCOPE.databaseName) + } + File(context.filesDir, "platoons/${SCOPE.storageId}").deleteRecursively() + GameTimeZonePreferences.clearScope(context, SCOPE.storageId) + } + + private companion object { + val SCOPE = PlatoonStorageScope("1".repeat(32)) + const val UID = 4242L + const val OTHER_UID = 4343L + val CLOSING_WEEK: LocalDate = LocalDate.of(2026, 7, 26) + val OPENING_WEEK: LocalDate = LocalDate.of(2026, 8, 2) + } +} diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt index 0f1c74d..001c234 100644 --- a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt @@ -56,6 +56,59 @@ class UserSettingsPreferencesIntegrationTest { ) } + @Test + fun anUnusableStoredTargetPackageDoesNotBlockCompleteBackups() { + // A v2.2 unified store copied the target package without validating it. + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 1) + .putString("target_package", "not a package") + .commit() + + val settings = UserSettingsPreferences.read(context) + + assertEquals(dev.gf2log.app.TargetPackagePreferences.DEFAULT_TARGET_PACKAGE, settings.targetPackage) + assertEquals(settings, AppBackupSettingsCodec.decode(AppBackupSettingsCodec.encode(settings))) + } + + @Test + fun cutlinesWrittenUnderTheExistingKeysRemainReadable() { + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 4) + .putLong("cutline_daily_merit", 90L) + .putInt("cutline_weekly_gunsmoke_attempts", 21) + .commit() + val storageId = "0".repeat(32) + context.getSharedPreferences("platoon_weekly_cutlines", Context.MODE_PRIVATE).edit() + .putLong("$storageId.weekly_score", 70_000L) + .putInt("$storageId.weekly_patrol", 5) + .commit() + + assertEquals( + WeeklyCutlines(dailyMerit = 90L, weeklyGunsmokeAttempts = 21), + UserSettingsPreferences.weeklyCutlines(context), + ) + val scoped = WeeklyCutlinePreferences(context, storageId) + assertEquals(WeeklyCutlines(weeklyGunsmokeScore = 70_000L, weeklyPatrolDays = 5), scoped.read()) + scoped.clear() + assertEquals(WeeklyCutlines(), scoped.read()) + } + + @Test + fun invalidStoredMemberOrderNeverBlocksAScopedSettingsReplacement() { + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 4) + .putString("member_order", "0,-5,7,7") + .commit() + val storageId = "0".repeat(32) + val store = ScopedAppSettingsStore(context, storageId) + + assertEquals(listOf(7L), UserSettingsPreferences.read(context).memberOrder) + store.replace(store.read().copy(memberOrder = listOf(3L))) + + assertEquals(listOf(3L), MemberOrderPreferences(context, storageId).read()) + assertEquals(listOf(7L), UserSettingsPreferences.memberOrder(context)) + } + private fun clearSettings() { listOf( "user_settings", @@ -64,6 +117,9 @@ class UserSettingsPreferencesIntegrationTest { "payload_history_options", "weekly_member_order", "weekly_cutlines", + "platoon_weekly_cutlines", + "platoon_member_order", + "platoon_timezones", ".app.MainActivity", ).forEach { name -> context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 20e6534..bcb10ff 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -26,12 +26,13 @@ #define PACKET_BUFFER_SIZE 65535 #define VPN_MTU 1500 #define SELECT_TIMEOUT_USEC 250000 -#define PURGE_INTERVAL_SECONDS 1 +#define PURGE_INTERVAL_MILLIS 1000 #define TUN_WRITE_TIMEOUT_MILLIS 100 #define TUN_WRITE_POLL_SLICE_MILLIS 25 typedef struct flow_state { int64_t id; + bool classified; bool inspect_payload; } flow_state_t; @@ -49,7 +50,6 @@ typedef struct capture_context { pthread_t thread; atomic_bool running; int tun_fd; - int64_t next_flow_id; uint64_t sent_bytes; uint64_t received_bytes; uint64_t inspected_bytes; @@ -57,6 +57,9 @@ typedef struct capture_context { static pthread_mutex_t context_mutex = PTHREAD_MUTEX_INITIALIZER; static capture_context_t *active_context = NULL; +// Process-wide so a flow ID is never reused by a later capture: unconfirmed Platoon candidates +// outlive the capture that observed them and refer to their flows by ID. +static _Atomic int64_t last_flow_id = 0; static bool clear_java_exception(capture_context_t *context, const char *operation) { if (!(*context->env)->ExceptionCheck(context->env)) { @@ -109,11 +112,17 @@ static void notify_payload( return; } - if (is_web_transport(payload, payload_length)) { - flow->inspect_payload = false; - return; + // Like the reference IgnoreTls addon, decide once at connection start. Later game + // segments may begin with TLS-like bytes and must not silently disable inspection. + if (!flow->classified) { + flow->classified = true; + if (is_web_transport(payload, payload_length)) { + flow->inspect_payload = false; + return; + } } + // Outgoing plaintext is used only for classification. if (is_sent) { return; } @@ -138,7 +147,6 @@ static void notify_payload( context->listener, context->payload_method, (jlong) flow->id, - is_sent ? JNI_TRUE : JNI_FALSE, bytes); clear_java_exception(context, "payload delivery"); } @@ -249,41 +257,50 @@ static void protect_socket_callback(zdtun_t *tunnel, socket_t socket_fd) { static int connection_open_callback(zdtun_t *tunnel, zdtun_conn_t *connection) { capture_context_t *context = (capture_context_t *) zdtun_userdata(tunnel); + const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); + // Only TCP streams can carry game frames, so other flows keep no state and never reach Java. + if (tuple->ipproto != IPPROTO_TCP) { + return 0; + } + flow_state_t *flow = calloc(1, sizeof(flow_state_t)); if (flow == NULL) { LOGE("Unable to allocate flow state"); return 1; } - - flow->id = ++context->next_flow_id; - flow->inspect_payload = zdtun_conn_get_5tuple(connection)->ipproto == IPPROTO_TCP; + flow->id = atomic_fetch_add(&last_flow_id, 1) + 1; + flow->inspect_payload = true; zdtun_conn_set_userdata(connection, flow); - const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); const int family = tuple->ipver == 4 ? AF_INET : AF_INET6; char local_address[INET6_ADDRSTRLEN] = {0}; char remote_address[INET6_ADDRSTRLEN] = {0}; - if (inet_ntop(family, &tuple->src_ip, local_address, sizeof(local_address)) != NULL && - inet_ntop(family, &tuple->dst_ip, remote_address, sizeof(remote_address)) != NULL) { - jstring local = (*context->env)->NewStringUTF(context->env, local_address); - jstring remote = (*context->env)->NewStringUTF(context->env, remote_address); - if (local != NULL && remote != NULL && - !clear_java_exception(context, "flow address allocation")) { - (*context->env)->CallVoidMethod( - context->env, - context->listener, - context->flow_opened_method, - (jlong) flow->id, - (jint) tuple->ipproto, - local, - (jint) ntohs(tuple->src_port), - remote, - (jint) ntohs(tuple->dst_port)); - clear_java_exception(context, "flow open"); - } - if (local != NULL) (*context->env)->DeleteLocalRef(context->env, local); - if (remote != NULL) (*context->env)->DeleteLocalRef(context->env, remote); + if (inet_ntop(family, &tuple->src_ip, local_address, sizeof(local_address)) == NULL || + inet_ntop(family, &tuple->dst_ip, remote_address, sizeof(remote_address)) == NULL) { + return 0; } + // A failed NewStringUTF leaves an OutOfMemoryError pending, and no further JNI call may be + // made until it is cleared. + JNIEnv *env = context->env; + jstring local = (*env)->NewStringUTF(env, local_address); + jstring remote = local == NULL ? NULL : (*env)->NewStringUTF(env, remote_address); + if (remote != NULL) { + (*env)->CallVoidMethod( + env, + context->listener, + context->flow_opened_method, + (jlong) flow->id, + (jint) tuple->ipproto, + local, + (jint) ntohs(tuple->src_port), + remote, + (jint) ntohs(tuple->dst_port)); + clear_java_exception(context, "flow open"); + (*env)->DeleteLocalRef(env, remote); + } else { + clear_java_exception(context, "flow address allocation"); + } + if (local != NULL) (*env)->DeleteLocalRef(env, local); return 0; } @@ -294,6 +311,11 @@ static void connection_close_callback(zdtun_t *tunnel, const zdtun_conn_t *conne return; } + // zdtun invokes this callback twice when the RST it sends while closing cannot be written to + // the TUN (send_to_client re-enters zdtun_conn_close). Detach first so the second invocation is + // a no-op instead of a duplicate onFlowClosed and a double free. + zdtun_conn_set_userdata((zdtun_conn_t *) connection, NULL); + if (context->env != NULL) { (*context->env)->CallVoidMethod( context->env, @@ -349,26 +371,37 @@ static void notify_traffic(capture_context_t *context) { clear_java_exception(context, "traffic update"); } -static void release_context(capture_context_t *context) { - if (context->env != NULL) { +/** Releases whatever the context still owns; global references need an attached env. */ +static void release_context(JNIEnv *env, capture_context_t *context) { + if (env != NULL) { if (context->listener != NULL) { - (*context->env)->DeleteGlobalRef(context->env, context->listener); + (*env)->DeleteGlobalRef(env, context->listener); + context->listener = NULL; } if (context->vpn_service != NULL) { - (*context->env)->DeleteGlobalRef(context->env, context->vpn_service); + (*env)->DeleteGlobalRef(env, context->vpn_service); + context->vpn_service = NULL; } } if (context->tun_fd >= 0) { close(context->tun_fd); + context->tun_fd = -1; } } +/** Joins a capture thread exactly once, then frees its context. */ +static void reap_context(JNIEnv *env, capture_context_t *context) { + pthread_join(context->thread, NULL); + release_context(env, context); + free(context); +} + static void *capture_thread_main(void *argument) { capture_context_t *context = (capture_context_t *) argument; if ((*context->vm)->AttachCurrentThread(context->vm, &context->env, NULL) != JNI_OK) { LOGE("Unable to attach capture thread to JVM"); - atomic_store(&context->running, false); + context->env = NULL; goto cleanup; } @@ -401,7 +434,8 @@ static void *capture_thread_main(void *argument) { goto detach; } - time_t last_purge = time(NULL); + // Monotonic, so a wall-clock adjustment cannot postpone purging or traffic updates. + int64_t last_purge = monotonic_millis(); LOGI("Native packet loop started"); while (atomic_load(&context->running)) { @@ -451,8 +485,8 @@ static void *capture_thread_main(void *argument) { zdtun_handle_fd(tunnel, &read_fds, &write_fds); } - time_t now = time(NULL); - if (now - last_purge >= PURGE_INTERVAL_SECONDS) { + const int64_t now = monotonic_millis(); + if (now - last_purge >= PURGE_INTERVAL_MILLIS) { zdtun_purge_expired(tunnel); notify_traffic(context); last_purge = now; @@ -465,24 +499,22 @@ static void *capture_thread_main(void *argument) { LOGI("Native packet loop stopped"); detach: - if (context->env != NULL && context->listener != NULL && context->stopped_method != NULL) { + if (context->listener != NULL && context->stopped_method != NULL) { (*context->env)->CallVoidMethod( context->env, context->listener, context->stopped_method); clear_java_exception(context, "capture stopped notification"); } + // Global references must be deleted while this thread is still attached. + release_context(context->env, context); (*context->vm)->DetachCurrentThread(context->vm); context->env = NULL; cleanup: - release_context(context); - pthread_mutex_lock(&context_mutex); - if (active_context == context) { - active_context = NULL; - } - pthread_mutex_unlock(&context_mutex); - free(context); + // Closing the TUN descriptor now lets Android tear down the VPN before the thread is reaped. + release_context(NULL, context); + atomic_store(&context->running, false); return NULL; } @@ -496,9 +528,21 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( (void) bridge; pthread_mutex_lock(&context_mutex); - if (active_context != NULL) { + capture_context_t *previous = active_context; + if (previous != NULL) { + if (atomic_load(&previous->running) || pthread_equal(pthread_self(), previous->thread)) { + pthread_mutex_unlock(&context_mutex); + return JNI_FALSE; + } + // The previous loop exited on its own and was never stopped; reap it before restarting. + active_context = NULL; pthread_mutex_unlock(&context_mutex); - return JNI_FALSE; + reap_context(env, previous); + pthread_mutex_lock(&context_mutex); + if (active_context != NULL) { + pthread_mutex_unlock(&context_mutex); + return JNI_FALSE; + } } capture_context_t *context = calloc(1, sizeof(capture_context_t)); @@ -518,8 +562,7 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( context->vpn_service = (*env)->NewGlobalRef(env, vpn_service); context->listener = (*env)->NewGlobalRef(env, listener); if (context->tun_fd < 0 || context->vpn_service == NULL || context->listener == NULL) { - context->env = env; - release_context(context); + release_context(env, context); free(context); pthread_mutex_unlock(&context_mutex); return JNI_FALSE; @@ -528,7 +571,7 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( jclass service_class = (*env)->GetObjectClass(env, vpn_service); jclass listener_class = (*env)->GetObjectClass(env, listener); context->protect_method = (*env)->GetMethodID(env, service_class, "protect", "(I)Z"); - context->payload_method = (*env)->GetMethodID(env, listener_class, "onPayload", "(JZ[B)V"); + context->payload_method = (*env)->GetMethodID(env, listener_class, "onPayload", "(J[B)V"); context->flow_opened_method = (*env)->GetMethodID( env, listener_class, @@ -546,23 +589,20 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( context->stopped_method == NULL || (*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); - context->env = env; - release_context(context); + release_context(env, context); free(context); pthread_mutex_unlock(&context_mutex); return JNI_FALSE; } atomic_init(&context->running, true); - active_context = context; if (pthread_create(&context->thread, NULL, capture_thread_main, context) != 0) { - active_context = NULL; - context->env = env; - release_context(context); + release_context(env, context); free(context); pthread_mutex_unlock(&context_mutex); return JNI_FALSE; } + active_context = context; pthread_mutex_unlock(&context_mutex); return JNI_TRUE; @@ -570,7 +610,6 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( JNIEXPORT void JNICALL Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStop(JNIEnv *env, jobject bridge) { - (void) env; (void) bridge; pthread_mutex_lock(&context_mutex); @@ -581,10 +620,13 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStop(JNIEnv *env, jobject } atomic_store(&context->running, false); - pthread_t thread = context->thread; + if (pthread_equal(pthread_self(), context->thread)) { + // A callback cannot join its own thread; the next start or stop reaps it. + pthread_mutex_unlock(&context_mutex); + return; + } + active_context = NULL; pthread_mutex_unlock(&context_mutex); - if (!pthread_equal(pthread_self(), thread)) { - pthread_join(thread, NULL); - } + reap_context(env, context); } diff --git a/app/src/main/java/dev/gf2log/app/BackupFlow.kt b/app/src/main/java/dev/gf2log/app/BackupFlow.kt new file mode 100644 index 0000000..7744cf4 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/BackupFlow.kt @@ -0,0 +1,124 @@ +package dev.gf2log.app + +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.management.BackupFileName +import dev.gf2log.app.management.InvalidBackupException +import dev.gf2log.app.management.PlatoonBackupManager +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.management.RestoredBackupKind +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.CaptureGuard +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import java.time.LocalDateTime + +/** + * Settings backup actions: export the selected Platoon or everything, and restore a chosen file. + * Each waits for capture to stop and shares [maintenanceKey] with other maintenance so only one + * runs at a time; a successful restore recreates the screen on the restored data. + */ +internal class BackupFlow( + private val activity: ComponentActivity, + private val work: BackgroundWork, + private val maintenanceKey: String, + private val scope: () -> PlatoonStorageScope?, +) { + private var pendingExportComplete = false + + private val exportDocument = activity.registerForActivityResult( + ActivityResultContracts.CreateDocument(PlatoonBackupManager.MIME_TYPE), + ) { uri -> + val target = scope() + if (uri == null || target == null) return@registerForActivityResult + val complete = pendingExportComplete + run( + progress = R.string.backup_exporting, + success = if (complete) R.string.full_backup_exported else R.string.status_backup_exported, + failure = { backupExportFailureMessage(complete) }, + ) { + DocumentAccess.openOutput(activity.contentResolver, uri).use { output -> + val manager = PlatoonBackupManager(activity, target) + if (complete) manager.exportFull(output) else manager.export(output) + } + } + } + + private val restoreDocument = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let(::restore) + } + + fun saveState(outState: Bundle) { + outState.putBoolean(STATE_EXPORT_COMPLETE, pendingExportComplete) + } + + fun restoreState(state: Bundle?) { + pendingExportComplete = state?.getBoolean(STATE_EXPORT_COMPLETE) == true + } + + fun export(complete: Boolean) = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_backup) { + pendingExportComplete = complete + val prefix = if (complete) "GF2logger-full" else "GF2logger-platoon" + exportDocument.launch("$prefix-${Labels.FILE_STAMP.format(LocalDateTime.now())}.${PlatoonBackupManager.FILE_EXTENSION}") + } + + fun confirmRestore() = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_backup) { + Dialogs.confirm(activity, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file, destructive = true) { + restoreDocument.launch(arrayOf(PlatoonBackupManager.MIME_TYPE, "application/zip", "application/octet-stream")) + } + } + + private fun restore(uri: Uri) { + if (DocumentAccess.displayName(activity.contentResolver, uri)?.let(BackupFileName::isValid) != true) { + Feedback.show(activity, R.string.invalid_full_backup, long = true) + return + } + if (work.isBusy(maintenanceKey)) return + Feedback.show(activity, R.string.backup_restoring) + work.run( + key = maintenanceKey, + task = { + DocumentAccess.openInput(activity.contentResolver, uri).use { input -> + PlatoonBackupManager.restoreSelected(activity, input) + } + }, + ) { result -> + result.fold( + onSuccess = { kind -> + Feedback.show( + activity, + if (kind == RestoredBackupKind.COMPLETE) R.string.full_backup_restored else R.string.status_backup_restored, + long = true, + ) + activity.recreate() + }, + onFailure = { Feedback.show(activity, fullBackupRestoreFailureMessage(it), long = true) }, + ) + } + } + + private fun run(progress: Int, success: Int, failure: (Throwable) -> Int, operation: () -> Unit) { + if (work.isBusy(maintenanceKey)) return + Feedback.show(activity, progress) + work.run(key = maintenanceKey, task = operation) { result -> + Feedback.show(activity, result.fold(onSuccess = { success }, onFailure = failure), long = true) + } + } + + private companion object { + const val STATE_EXPORT_COMPLETE = "settings.export_complete" + } +} + +/** Known validation failures blame the selected file; anything else is an operational failure. */ +internal fun fullBackupRestoreFailureMessage(error: Throwable): Int = + if (error is InvalidBackupException) R.string.invalid_full_backup else R.string.full_backup_restore_failed + +/** A Platoon-only export failure must not claim that the complete backup failed. */ +internal fun backupExportFailureMessage(complete: Boolean): Int = + if (complete) R.string.full_backup_export_failed else R.string.status_backup_export_failed + diff --git a/app/src/main/java/dev/gf2log/app/BaseActivity.kt b/app/src/main/java/dev/gf2log/app/BaseActivity.kt new file mode 100644 index 0000000..195ab9d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/BaseActivity.kt @@ -0,0 +1,93 @@ +package dev.gf2log.app + +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.Lifecycle +import dev.gf2log.app.capture.PendingPlatoonAdmissionStore + +/** + * Applies the persisted language and appearance before views exist, draws edge to edge, and + * hosts the pending-Platoon admission prompt for every screen that supports it. + */ +abstract class BaseActivity : ComponentActivity() { + private var attachedLanguage = LanguagePreferences.DEFAULT_LANGUAGE + private var attachedTheme = ThemePreferences.SYSTEM + + override fun attachBaseContext(newBase: Context) { + attachedLanguage = LanguagePreferences.get(newBase) + attachedTheme = preferredTheme(newBase) + val localized = LanguagePreferences.wrap(newBase, attachedLanguage) + super.attachBaseContext(ThemePreferences.wrap(localized, attachedTheme)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + // The default detector reads this Activity's resources, which already carry the app's + // own light/dark choice rather than the system setting. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + ) + super.onCreate(savedInstanceState) + } + + /** Allows a purpose-built surface to use a stable presentation theme. */ + protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context) + + protected open fun supportsPendingPlatoonAdmission(): Boolean = true + + private var pendingCandidates = 0 + private val admissionListener: (List) -> Unit = { candidates -> + PendingPlatoonAdmissionPrompt.onCandidatesChanged(this, candidates) + // A confirmed candidate becomes the active Platoon; let the screen rebind to it. + if (candidates.size < pendingCandidates && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + onScreenResumed() + } + pendingCandidates = candidates.size + } + + override fun onStart() { + super.onStart() + if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { + PendingPlatoonAdmissionStore.addListener(admissionListener) + } + } + + override fun onStop() { + PendingPlatoonAdmissionStore.removeListener(admissionListener) + PendingPlatoonAdmissionPrompt.dismiss(this) + super.onStop() + } + + override fun onDestroy() { + dev.gf2log.app.ui.Dialogs.dismissAll(this) + super.onDestroy() + } + + final override fun onResume() { + super.onResume() + if ( + LanguagePreferences.get(this) != attachedLanguage || + preferredTheme(this) != attachedTheme + ) { + recreate() + return + } + foreground = java.lang.ref.WeakReference(this) + if (!isFinishing) onScreenResumed() + } + + /** Called from onResume only when this instance remains current. */ + protected open fun onScreenResumed() = Unit + + companion object { + @Volatile + private var foreground = java.lang.ref.WeakReference(null) + + /** The most recently resumed screen, used to refresh after work that outlived its caller. */ + internal fun foregroundScreen(): BaseActivity? = + foreground.get()?.takeUnless { it.isFinishing || it.isDestroyed } + } +} diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt new file mode 100644 index 0000000..348bc21 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -0,0 +1,322 @@ +package dev.gf2log.app + +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.management.CsvImportCheckpointManager +import dev.gf2log.app.management.CsvImportPreviewAnalyzer +import dev.gf2log.app.management.PlatoonCsvImportStore +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.CaptureGuard +import dev.gf2log.app.ui.captureActive +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text + +/** + * Roster CSV import: choose the destination Platoon, pick files, preview their impact, then apply + * behind an automatic checkpoint. Payload 21917 carries no Platoon identity, so the destination is + * always an explicit user choice that is re-verified before anything is retained. + */ +internal class CsvImportFlow( + private val activity: BaseActivity, + private val work: BackgroundWork, + private val boundScope: () -> PlatoonStorageScope?, + private val onProgress: (CharSequence?) -> Unit, +) { + private var pickerTarget: String? = null + private var launchPickerWhenReady = false + private var pending: PendingImport? = null + + private val picker = activity.registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + val target = pickerTarget?.let(::resolveTarget) + if (uris.isNullOrEmpty()) { + pickerTarget = null + return@registerForActivityResult + } + if (target == null) { + pickerTarget = null + Feedback.show(activity, R.string.csv_import_target_changed, long = true) + return@registerForActivityResult + } + prepare(uris.distinct(), target) + } + + fun saveState(outState: Bundle) { + pickerTarget?.let { outState.putString(STATE_TARGET, it) } + outState.putBoolean(STATE_LAUNCH, launchPickerWhenReady) + } + + fun restoreState(state: Bundle?) { + pickerTarget = state?.getString(STATE_TARGET) + launchPickerWhenReady = state?.getBoolean(STATE_LAUNCH) == true + } + + /** Called once the hosting screen is bound to its final scope. */ + fun onScreenReady() { + if (!launchPickerWhenReady) return + launchPickerWhenReady = false + launchPicker() + } + + fun start() = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_csv_import) { + PlatoonCsvImportPrompt.show(activity) { profile -> beginFor(profile) } + } + + fun undo() = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_csv_import) { + val scope = boundScope() ?: run { + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) + return@whenStopped + } + // One undo at a time: a second restore would run against the already-restored state. + if (work.isBusy(UNDO)) return@whenStopped + work.run(key = UNDO, task = { CsvImportCheckpointManager(activity, scope).canUndo() }) { result -> + if (!result.getOrDefault(false)) { + Feedback.show(activity, R.string.no_csv_import_checkpoint) + return@run + } + Dialogs.confirm(activity, R.string.undo_last_csv_import, R.string.undo_last_csv_import_warning, R.string.undo) confirm@{ + if (work.isBusy(UNDO)) return@confirm + onProgress(activity.getString(R.string.csv_import_undoing)) + work.run(key = UNDO, task = { CsvImportCheckpointManager(activity, scope).restore() }) { restore -> + onProgress(null) + Feedback.show( + activity, + if (restore.isSuccess) R.string.csv_import_undone else R.string.csv_import_undo_failed, + long = restore.isFailure, + ) + } + } + } + } + + private fun beginFor(profile: PlatoonProfile) { + if (!PlatoonProfileRegistry(activity).setActive(profile.storageId)) { + Feedback.show(activity, R.string.csv_import_target_changed, long = true) + return + } + pickerTarget = profile.storageId + if (boundScope()?.storageId == profile.storageId) { + launchPicker() + } else { + // Rebind every scoped helper to the newly selected Platoon before reading files. + launchPickerWhenReady = true + activity.recreate() + } + } + + private fun launchPicker() { + if (pickerTarget?.let(::resolveTarget) == null) { + pickerTarget = null + return + } + picker.launch(arrayOf("text/csv", "text/comma-separated-values", "text/plain")) + } + + private fun resolveTarget(storageId: String): Target? { + val active = PlatoonProfileRegistry(activity).active() ?: return null + val scope = PlatoonStorageScope(storageId) + if (active.storageId != storageId || active.legacy || boundScope() != scope) return null + return Target(scope, active) + } + + private fun prepare(sources: List, target: Target) { + onProgress(activity.getString(R.string.csv_import_preparing_preview)) + work.run( + task = { + // Recover an interrupted prior import before reading the preview baseline. + CsvImportCheckpointManager(activity, target.scope) + require(sources.size <= MAX_FILES) { "Too many Platoon CSV files were selected" } + val directory = target.scope.retainedCsvDirectory(activity) + val store = PlatoonCsvImportStore(directory) + var bytes = 0L + val selected = sources.map { source -> + DocumentAccess.openInput(activity.contentResolver, source).use(store::prepare).also { + bytes += it.byteCount + require(bytes <= MAX_BYTES) { "Selected Platoon CSV files exceed the total import size limit" } + } + } + val unique = selected.distinctBy(PlatoonCsvImportStore.PreparedImport::fileName) + val repository = PlatoonRepository(activity, target.scope) + repository.reconcileRetainedCsvFiles(directory) + val duplicates = CsvImportPreviewAnalyzer.duplicateFileNames(unique, repository.representedSnapshotSources()) + val analyzed = CsvImportPreviewAnalyzer.analyze( + prepared = unique, + duplicateFileNames = duplicates, + existingMembers = repository.listMemberStatuses(), + latestSnapshot = repository.listSnapshots(limit = 1).firstOrNull(), + ) + PendingImport( + target = target, + prepared = unique, + duplicateFileNames = duplicates, + preview = analyzed.copy( + validatedFiles = selected.size, + duplicateFiles = analyzed.duplicateFiles + selected.size - unique.size, + totalBytes = bytes, + ), + ) + }, + ) { result -> + onProgress(null) + result.fold( + onSuccess = { + pending = it + showPreview(it) + }, + onFailure = { + pickerTarget = null + Feedback.show(activity, R.string.status_platoon_csv_import_failed, long = true) + }, + ) + } + } + + private fun showPreview(import: PendingImport) { + val preview = import.preview + val profile = import.target.profile + val range = if (preview.firstCapture == null || preview.lastCapture == null) { + activity.getString(R.string.none) + } else { + val zone = java.time.ZoneId.systemDefault() + activity.getString( + R.string.csv_import_capture_range, + Labels.format(Labels.DATE_TIME, preview.firstCapture, zone), + Labels.format(Labels.DATE_TIME, preview.lastCapture, zone), + ) + } + val facts = listOf( + R.string.csv_preview_files to preview.validatedFiles.toString(), + R.string.csv_preview_already_present to preview.duplicateFiles.toString(), + R.string.csv_preview_historical to preview.historicalFiles.toString(), + R.string.csv_preview_members to preview.uniqueMembers.toString(), + R.string.csv_preview_new_members to preview.newMembers.toString(), + R.string.csv_preview_name_changes to preview.nameDifferences.toString(), + R.string.csv_preview_joins to preview.potentialJoins.toString(), + R.string.csv_preview_withdrawals to preview.potentialWithdrawals.toString(), + R.string.csv_preview_size to activity.getString(R.string.kib, (preview.totalBytes + 1023) / 1024), + R.string.csv_preview_range to range, + ) + val content = Dialogs.content(activity) { + addView(activity.panel(emphasized = true, padding = 12) { + addView(activity.text(activity.getString(R.string.csv_import_destination_label), TextRole.LABEL), activity.lp()) + addView(activity.text(profile.platoonName, TextRole.SUBTITLE), activity.lp(top = 2)) + addView(activity.text(Labels.profileIdentity(activity, profile), TextRole.CAPTION), activity.lp()) + addView(activity.text(activity.getString(R.string.csv_import_destination_warning), TextRole.CAPTION) { + setTextColor(activity.getColor(R.color.accent_text)) + }, activity.lp(top = 6)) + }, activity.lp()) + facts.forEach { (label, value) -> + addView(activity.row(Gravity.TOP) { + minimumHeight = (32 * resources.displayMetrics.density).toInt() + addView(activity.text(activity.getString(label), TextRole.SECONDARY), activity.lp(width = 0, weight = 1f)) + addView(activity.text(value, TextRole.BODY) { gravity = Gravity.END }, activity.lp(WRAP, WRAP, start = 12)) + }, activity.lp(top = 4)) + } + addView(activity.text(activity.getString(R.string.csv_import_no_changes_yet), TextRole.CAPTION), activity.lp(top = 8)) + } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.csv_import_preview_title) + .setView(content) + .setNegativeButton(R.string.cancel) { _, _ -> clear() } + .setOnCancelListener { clear() } + .setPositiveButton(R.string.import_action) { _, _ -> apply(import) }, + ) + } + + private fun clear() { + pending = null + pickerTarget = null + } + + private fun apply(import: PendingImport) { + val target = resolveTarget(import.target.scope.storageId) + if (pending !== import || captureActive || target == null || + target.profile.client != import.target.profile.client || + target.profile.platoonId != import.target.profile.platoonId + ) { + clear() + Feedback.show( + activity, + if (captureActive) R.string.stop_capture_before_csv_import else R.string.csv_import_target_changed, + long = true, + ) + return + } + pending = null + onProgress(activity.getString(R.string.csv_import_applying)) + work.run( + task = { + val directory = import.target.scope.retainedCsvDirectory(activity) + val store = PlatoonCsvImportStore(directory) + val planned = import.prepared + .filterNot { it.fileName in import.duplicateFileNames } + .mapTo(mutableSetOf()) { it.fileName } + CsvImportCheckpointManager(activity, import.target.scope).runImport(planned) { + var retained = 0 + var duplicates = 0 + import.prepared.forEach { prepared -> + if (store.retain(prepared).duplicate) duplicates += 1 else retained += 1 + } + val imported = PlatoonRepository(activity, import.target.scope).reconcileRetainedCsvFiles(directory) + Summary(retained, duplicates, imported) + } + }, + ) { result -> + onProgress(null) + pickerTarget = null + result.fold( + onSuccess = { summary -> + Feedback.show( + activity, + activity.getString( + R.string.status_platoon_csv_imported_with_checkpoint, + summary.retained, + summary.imported.imported, + summary.imported.historical, + summary.duplicates + summary.imported.skipped, + ), + long = true, + ) + }, + onFailure = { Feedback.show(activity, R.string.status_platoon_csv_import_failed, long = true) }, + ) + } + } + + private data class Target(val scope: PlatoonStorageScope, val profile: PlatoonProfile) + + private data class PendingImport( + val target: Target, + val prepared: List, + val duplicateFileNames: Set, + val preview: CsvImportPreviewAnalyzer.Preview, + ) + + private data class Summary( + val retained: Int, + val duplicates: Int, + val imported: PlatoonRepository.ImportResult, + ) + + private companion object { + const val UNDO = "csv_import.undo" + const val MAX_FILES = 64 + const val MAX_BYTES = 16L * 1024 * 1024 + const val STATE_TARGET = "csv_import.target" + const val STATE_LAUNCH = "csv_import.launch_picker" + } +} diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index f9a35e1..b05c339 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -3,27 +3,32 @@ package dev.gf2log.app import android.app.DatePickerDialog import android.app.TimePickerDialog import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.Gravity import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText import android.widget.LinearLayout import android.widget.TextView -import android.widget.Toast import dev.gf2log.app.management.MembershipBoundaryValue +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId -import java.time.format.DateTimeFormatter /** * Calendar input with an optional clock value. * - * Dates are stored explicitly so a date-only manual boundary remains stable if - * the device timezone later changes. A date-only value uses local midnight as - * its representative Instant, while [MembershipBoundaryValue.timeKnown] - * preserves that the time itself is unknown. + * Dates are stored explicitly so a date-only manual boundary remains stable if the device + * timezone later changes. A date-only value uses local midnight as its representative Instant, + * while [MembershipBoundaryValue.timeKnown] preserves that the time itself is unknown. */ internal class DateTimePickerInput( context: Context, @@ -32,7 +37,7 @@ internal class DateTimePickerInput( initialDate: LocalDate? = null, initialTimeKnown: Boolean = initialValue != null, private val dateRequired: Boolean = false, - editable: Boolean = true, + private val editable: Boolean = true, private val zone: ZoneId = ZoneId.systemDefault(), ) : LinearLayout(context) { private val selection = MembershipBoundaryDraft( @@ -42,128 +47,104 @@ internal class DateTimePickerInput( zone = zone, ) - val date: LocalDate? - get() = selection.date - val boundary: MembershipBoundaryValue? get() = selection.boundary - private val dateInput = pickerField(context.getString(R.string.date_unset)) { - showDatePicker() - } - private val timeInput = pickerField(context.getString(R.string.time_optional)) { - if (selection.date == null) { - TransientMessage.show(context, R.string.select_date_first) - } else { - showTimePicker() - } - } - private val clearDateButton = Button(context).apply { - text = context.getString(R.string.clear_date) - setOnClickListener { - selection.clearDate() - renderValue() - } - } - private val clearTimeButton = Button(context).apply { - text = context.getString(R.string.clear_time) - setOnClickListener { - selection.clearTime() - renderValue() - } + private val dateField = pickerField(R.drawable.ic_today) { showDatePicker() } + private val timeField = pickerField(R.drawable.ic_schedule) { showTimePicker() } + private val clearButton = context.iconButton(R.drawable.ic_close, context.getString(R.string.clear_date)) { + if (selection.time != null) selection.clearTime() else selection.clearDate() + renderValue() } init { orientation = VERTICAL - addView(TextView(context).apply { - text = label - setPadding(0, dp(6), 0, dp(2)) - }, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - addView(LinearLayout(context).apply { - orientation = HORIZONTAL - addView(dateInput, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - if (!dateRequired) { - addView( - clearDateButton, - LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - }, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - addView(LinearLayout(context).apply { - orientation = HORIZONTAL - addView(timeInput, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView( - clearTimeButton, - LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - }, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - setBoundaryEditable(editable) + addView(context.text(label, TextRole.LABEL) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) + }, context.lp()) + addView(context.row { + addView(dateField, context.lp(width = 0, weight = 1.3f)) + addView(timeField, context.lp(width = 0, weight = 1f, start = 8)) + addView(clearButton, context.lp(48, 48, start = 4)) + }, context.lp()) + if (!editable) { + addView(context.text(context.getString(R.string.exact_boundary_read_only), TextRole.CAPTION) { + setPadding(context.dp(4), context.dp(4), context.dp(4), 0) + }, context.lp()) + } renderValue() } - private fun pickerField(hintText: String, onClick: () -> Unit) = - EditText(context).apply { - isFocusable = false - isClickable = true - hint = hintText - setSingleLine(true) - setOnClickListener { onClick() } + private fun pickerField(icon: Int, onClick: () -> Unit): TextView = context.text(null, TextRole.BODY) { + gravity = Gravity.CENTER_VERTICAL + // Grows with the font size instead of clipping; 48dp stays the minimum touch target. + minHeight = context.dp(48) + setPadding(context.dp(12), context.dp(6), context.dp(8), context.dp(6)) + maxLines = 1 + background = GradientDrawable().apply { + setColor(context.getColor(R.color.surface_variant)) + cornerRadius = context.dpf(12f) } + val image = requireNotNull(context.getDrawable(icon)).mutate() + image.setTint(context.getColor(R.color.text_secondary)) + val size = context.dp(18) + image.setBounds(0, 0, size, size) + setCompoundDrawablesRelative(image, null, null, null) + compoundDrawablePadding = context.dp(8) + isClickable = true + isFocusable = true + setOnClickListener { if (isEnabled) onClick() } + } private fun showDatePicker() { val seed = selection.date ?: LocalDate.now(zone) - DatePickerDialog( - context, - { _, year, month, day -> - selection.selectDate(LocalDate.of(year, month + 1, day)) - renderValue() - }, - seed.year, - seed.monthValue - 1, - seed.dayOfMonth, - ).show() + Dialogs.showTracked( + DatePickerDialog( + context, + { _, year, month, day -> + selection.selectDate(LocalDate.of(year, month + 1, day)) + renderValue() + }, + seed.year, + seed.monthValue - 1, + seed.dayOfMonth, + ), + ) } private fun showTimePicker() { val seed = selection.time ?: LocalTime.now(zone).withSecond(0).withNano(0) - TimePickerDialog( - context, - { _, hour, minute -> - selection.selectTime(LocalTime.of(hour, minute)) - renderValue() - }, - seed.hour, - seed.minute, - true, - ).show() - } - - private fun setBoundaryEditable(editable: Boolean) { - dateInput.isEnabled = editable - timeInput.isEnabled = editable - clearDateButton.visibility = if (editable && !dateRequired) View.VISIBLE else View.GONE - clearTimeButton.visibility = if (editable) View.VISIBLE else View.GONE - alpha = if (editable) 1f else 0.72f + Dialogs.showTracked( + TimePickerDialog( + context, + { _, hour, minute -> + selection.selectTime(LocalTime.of(hour, minute)) + renderValue() + }, + seed.hour, + seed.minute, + android.text.format.DateFormat.is24HourFormat(context), + ), + ) } private fun renderValue() { - dateInput.setText(selection.date?.format(DATE).orEmpty()) - timeInput.setText(selection.time?.format(TIME).orEmpty()) - timeInput.isEnabled = dateInput.isEnabled && selection.date != null - clearTimeButton.isEnabled = selection.date != null && selection.time != null - } - - private fun dp(value: Int): Int = - (value * resources.displayMetrics.density).toInt() - - private companion object { - val DATE: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE - val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + val date = selection.date + val time = selection.time + dateField.text = date?.format(Labels.DATE) ?: context.getString(R.string.date_unset) + dateField.setTextColor(context.getColor(if (date == null) R.color.text_secondary else R.color.text_primary)) + timeField.text = time?.format(Labels.TIME) ?: context.getString(R.string.time_optional) + timeField.setTextColor(context.getColor(if (time == null) R.color.text_secondary else R.color.text_primary)) + dateField.isEnabled = editable + timeField.isEnabled = editable && date != null + timeField.alpha = if (timeField.isEnabled) 1f else 0.6f + dateField.contentDescription = context.getString(R.string.date_field_description, dateField.text) + timeField.contentDescription = context.getString(R.string.time_field_description, timeField.text) + val canClear = editable && (time != null || (!dateRequired && date != null)) + clearButton.visibility = if (editable) View.VISIBLE else View.GONE + clearButton.isEnabled = canClear + clearButton.contentDescription = context.getString( + if (time != null) R.string.clear_time else R.string.clear_date, + ) } } diff --git a/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt new file mode 100644 index 0000000..ff28cb0 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt @@ -0,0 +1,94 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.text.InputType +import dev.gf2log.app.discord.DiscordWebhookSecretStore +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField + +/** Settings row for the Discord webhook: shows whether one is stored, and edits or clears it. */ +internal class DiscordWebhookRow(private val activity: Activity, private val work: BackgroundWork) { + val view = activity.column() + + init { + refresh() + } + + private fun refresh() { + work.run(task = { DiscordWebhookSecretStore(activity).read() != null }) { result -> + val configured = result.getOrDefault(false) + view.removeAllViews() + view.addView(activity.group(listOf( + activity.navigationRow( + activity.getString(if (configured) R.string.discord_webhook_configured else R.string.discord_webhook_not_configured), + activity.getString(R.string.discord_webhook_summary), + R.drawable.ic_discord, + trailing = activity.getString(if (configured) R.string.change else R.string.set_up), + ) { edit(configured) }, + )), activity.lp()) + } + } + + private fun edit(configured: Boolean) = with(activity) { + val input = textField( + getString(R.string.discord_webhook_hint), + id = R.id.webhook_input, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, + ) + val error = errorLine() + val builder = Dialogs.builder(this) + .setTitle(R.string.discord_webhook) + .setView(Dialogs.content(this) { + addView(text(getString(R.string.discord_webhook_description), TextRole.SECONDARY), lp()) + addView(input, lp(top = 12)) + addView(error, lp(top = 4)) + }) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.save_discord_webhook, null) + if (configured) builder.setNeutralButton(R.string.clear_discord_webhook, null) + Dialogs.show(builder) { dialog -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val value = input.text.toString() + if (work.isBusy(WRITE)) return@setOnClickListener + work.run(key = WRITE, task = { DiscordWebhookSecretStore(this).save(value) }) { result -> + if (result.isSuccess) { + dialog.dismiss() + Feedback.show(this, R.string.discord_webhook_saved) + refresh() + } else { + error.showMessage(getString(R.string.discord_webhook_invalid)) + } + } + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { + setTextColor(getColor(R.color.destructive_action)) + setOnClickListener { + if (work.isBusy(WRITE)) return@setOnClickListener + work.run(key = WRITE, task = { DiscordWebhookSecretStore(activity).clear() }) { result -> + dialog.dismiss() + Feedback.show( + activity, + if (result.isSuccess) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, + ) + refresh() + } + } + } + } + } + + private companion object { + const val WRITE = "webhook" + } +} diff --git a/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt new file mode 100644 index 0000000..cb8da56 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt @@ -0,0 +1,218 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import dev.gf2log.app.capture.CaptureNotice +import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureSnapshot +import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.capture.format +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text +import dev.gf2log.protocol.Gfl2PayloadDecoder + +/** + * Home's capture panel: the capture phase and its latest notice, the guided checklist, installed + * clients, and the start or stop actions. It only renders [CaptureSnapshot]s; the hosting screen + * owns permissions and the capture service. + */ +internal class HomeCapturePanel( + private val context: Context, + private val onStart: (once: Boolean) -> Unit, + private val onStop: () -> Unit, +) { + private val stateDot = View(context).apply { + background = GradientDrawable().apply { shape = GradientDrawable.OVAL } + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + private val stateTitle = context.text(null, TextRole.HEADLINE) { + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } + private val stateDetail = context.text(null, TextRole.SECONDARY) { maxLines = 4 } + private val checklist = context.column { visibility = View.GONE } + private val captureActions = context.row(Gravity.TOP) + private val clients = context.column() + private var lastPhase: CapturePhase? = null + + val view: LinearLayout = context.panel(outlined = true) { + addView(context.row { + addView(stateDot, context.lp(10, 10, end = 8)) + addView(context.text(context.getString(R.string.capture_status_label), TextRole.LABEL), context.lp(width = 0, weight = 1f)) + }, context.lp()) + addView(stateTitle, context.lp(top = 6)) + addView(stateDetail, context.lp(top = 2)) + addView(checklist, context.lp(top = 8)) + addView(clients, context.lp(top = 12)) + addView(captureActions, context.lp(top = 12)) + } + + init { + renderClients() + } + + /** Rebuilt on every resume so installing or removing a client shows up immediately. */ + fun renderClients() = with(context) { + clients.removeAllViews() + SupportedGamePackages.all.forEachIndexed { index, packageName -> + val installed = isInstalled(packageName) + val client = clientName(packageName) + val status = getString(if (installed) R.string.client_installed else R.string.client_not_installed) + clients.addView(row { + minimumHeight = dp(40) + isFocusable = true + contentDescription = "$client. $packageName. $status" + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(text(client, TextRole.BODY), lp()) + addView(text(packageName, TextRole.CAPTION) { + typeface = Typeface.MONOSPACE + textSize = 11f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.MIDDLE + }, lp()) + }, lp(width = 0, weight = 1f)) + addView(badge( + status, + // Page-colored fill keeps the badge legible on the green running panel too. + if (installed) R.color.app_background else R.color.surface_variant, + if (installed) R.color.success_text else R.color.text_secondary, + ).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) + }, lp(top = if (index == 0) 0 else 6)) + } + } + + fun render(snapshot: CaptureSnapshot) = with(context) { + val phase = snapshot.phase + val (title, color) = when (phase) { + CapturePhase.STOPPED -> R.string.capture_ready to R.color.text_primary + CapturePhase.STARTING -> R.string.status_preparing to R.color.accent_text + CapturePhase.RUNNING -> R.string.capture_running to R.color.success_text + } + stateTitle.text = getString(title) + stateTitle.setTextColor(getColor(color)) + // A running capture tints the whole panel so its state reads from across the room. + (view.background as GradientDrawable).apply { + setColor(getColor(if (phase == CapturePhase.RUNNING) R.color.success_surface else R.color.surface)) + setStroke(dp(1), getColor(if (phase == CapturePhase.RUNNING) R.color.success_mark else R.color.outline)) + } + (stateDot.background as GradientDrawable).setColor( + getColor( + when (phase) { + CapturePhase.STOPPED -> R.color.outline_strong + CapturePhase.STARTING -> R.color.accent + CapturePhase.RUNNING -> R.color.success_mark + }, + ), + ) + val notice = snapshot.notice + stateDetail.text = when { + notice?.notice == CaptureNotice.CAPTURING -> getString(R.string.capturing_clients, clientNames(snapshot.capturedTargets)) + notice != null && notice.notice != CaptureNotice.PREPARING -> notice.format(this) + phase == CapturePhase.STOPPED -> getString(R.string.capture_ready_detail) + else -> getString(R.string.capture_preparing_detail) + } + stateDetail.setTextColor( + getColor( + when (notice?.notice?.severity) { + CaptureNotice.Severity.ERROR -> R.color.destructive_action + CaptureNotice.Severity.WARNING -> R.color.warning_text + else -> R.color.text_secondary + }, + ), + ) + renderChecklist(snapshot.guided) + if (phase != lastPhase) { + lastPhase = phase + renderActions(phase) + } + } + + private fun clientNames(packages: List): String { + val names = packages.map(::clientName) + return when (names.size) { + 0 -> context.getString(R.string.none) + 1 -> names.single() + else -> context.getString(R.string.clients_and, names[0], names.drop(1).joinToString(", ")) + } + } + + private fun clientName(packageName: String) = when (packageName) { + SupportedGamePackages.HAOPLAY -> "HaoPlay" + SupportedGamePackages.DARKWINTER -> "Darkwinter" + else -> packageName + } + + private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) = with(context) { + checklist.removeAllViews() + checklist.visibility = if (guided == null) View.GONE else View.VISIBLE + guided ?: return@with + listOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE to R.string.checklist_identity, + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS to R.string.checklist_roster, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY to R.string.checklist_activity, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES to R.string.checklist_updates, + ).forEach { (type, label) -> + val done = type in guided.capturedPayloadTypes + checklist.addView(row { + minimumHeight = dp(32) + isFocusable = true + contentDescription = getString(label) + ". " + getString(if (done) R.string.captured else R.string.waiting) + addView(icon( + if (done) R.drawable.ic_check_circle else R.drawable.ic_circle_outline, + if (done) R.color.success_mark else R.color.outline_strong, + sizeDp = 20, + ), lp(20, 20, end = 10)) + addView(text(getString(label), TextRole.BODY) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + if (!done) setTextColor(getColor(R.color.text_secondary)) + }, lp(width = 0, weight = 1f)) + }, lp()) + } + } + + private fun renderActions(phase: CapturePhase) = with(context) { + captureActions.removeAllViews() + if (phase == CapturePhase.STOPPED) { + captureActions.addView( + button(getString(R.string.capture_one_roster), Controls.Role.PRIMARY, R.drawable.ic_bolt) { onStart(true) }, + lp(width = 0, weight = 1f, end = 4), + ) + captureActions.addView( + button(getString(R.string.prepare_capture), Controls.Role.CAPTURE, R.drawable.ic_play) { onStart(false) }, + lp(width = 0, weight = 1f, start = 4), + ) + } else { + captureActions.addView( + button(getString(R.string.stop_capture), Controls.Role.DESTRUCTIVE, R.drawable.ic_stop) { onStop() }, + lp(), + ) + } + } + + private fun isInstalled(packageName: String): Boolean = runCatching { + val packages = context.packageManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packages.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packages.getPackageInfo(packageName, 0) + } + }.isSuccess +} diff --git a/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt b/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt new file mode 100644 index 0000000..737198b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt @@ -0,0 +1,326 @@ +package dev.gf2log.app + +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.history.SavedHistoryStore +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import java.io.File + +/** + * Home's parsed-packet history: the recent and saved tabs, a collapsed list, and a long-press + * selection mode that saves or deletes entries. Back leaves selection before leaving Home. + */ +internal class HomePacketHistory( + private val activity: ComponentActivity, + private val work: BackgroundWork, + private val scope: PlatoonStorageScope?, +) { + val view: LinearLayout = activity.column() + + private val historyStore: CaptureHistoryStore? + private val savedStore: SavedHistoryStore? + private var showingSaved = false + private var selecting = false + private val selectedIds = linkedSetOf() + private var showAll = false + private var recent = emptyList() + private var saved = emptyList() + private var selectionCount: TextView? = null + private val selectionButtons = mutableListOf() + + private val exitSelection = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = setSelecting(false) + } + + init { + val root = scope?.rootDirectory(activity) + historyStore = root?.let { CaptureHistoryStore(File(it, CaptureHistoryStore.HISTORY_DIRECTORY)) } + savedStore = root?.let { SavedHistoryStore(File(it, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) } + activity.onBackPressedDispatcher.addCallback(activity, exitSelection) + } + + fun restoreState(state: Bundle?) { + state ?: return + showingSaved = state.getBoolean(STATE_SHOWING_SAVED) + showAll = state.getBoolean(STATE_SHOW_ALL) + selecting = state.getBoolean(STATE_SELECTING) + state.getStringArrayList(STATE_SELECTED)?.let(selectedIds::addAll) + exitSelection.isEnabled = selecting + } + + fun saveState(outState: Bundle) { + outState.putBoolean(STATE_SHOWING_SAVED, showingSaved) + outState.putBoolean(STATE_SHOW_ALL, showAll) + outState.putBoolean(STATE_SELECTING, selecting) + outState.putStringArrayList(STATE_SELECTED, ArrayList(selectedIds)) + } + + fun load() { + val recentStore = historyStore + val savedEntries = savedStore + if (recentStore == null || savedEntries == null) { + render() + return + } + work.run(key = LOAD, task = { recentStore.list() to savedEntries.list() }) { result -> + result.getOrNull()?.let { (recentEntries, savedList) -> + recent = recentEntries + saved = savedList + val ids = (if (showingSaved) saved else recent).mapTo(mutableSetOf()) { it.id } + selectedIds.retainAll(ids) + } + render() + } + } + + private fun render(): Unit = with(activity) { + view.removeAllViews() + val entries = if (showingSaved) saved else recent + view.addView(row { + addView(sectionHeader(getString(R.string.packet_history_section)), lp(width = 0, weight = 1f)) + if (entries.isNotEmpty()) { + addView( + button(getString(if (selecting) R.string.done else R.string.select), Controls.Role.TERTIARY) { + setSelecting(!selecting) + }, + lp(WRAP, WRAP, top = 8), + ) + } + }, lp()) + view.addView(Controls.segmentedControl( + context = this, + options = listOf( + RECENT to getString(R.string.recent_packets_tab, recent.size, CaptureHistoryStore.MAX_ENTRIES), + SAVED to getString(R.string.saved_packets_tab, saved.size, SavedHistoryStore.MAX_ENTRIES), + ), + selectedValue = if (showingSaved) SAVED else RECENT, + ) { value -> + showingSaved = value == SAVED + showAll = false + setSelecting(false) + }, lp()) + if (entries.isEmpty()) { + view.addView(text( + getString( + when { + scope == null -> R.string.no_platoon_history + showingSaved -> R.string.no_saved_packets + else -> R.string.no_parsed_packets + }, + ), + TextRole.SECONDARY, + ) { setPadding(dp(4), dp(16), dp(4), dp(8)) }, lp()) + return@with + } + if (selecting) view.addView(selectionActions(), lp(top = 8)) + val visible = if (showAll) entries else entries.take(COLLAPSED_COUNT) + view.addView(panel(padding = 4) { + visible.forEachIndexed { index, entry -> + if (index > 0) addView(divider(insetStartDp = 12)) + addView(historyRow(entry), lp()) + } + }, lp(top = 8)) + if (!showAll && entries.size > COLLAPSED_COUNT) { + view.addView(button(getString(R.string.show_all_count, entries.size), Controls.Role.TERTIARY) { + showAll = true + render() + }, lp(top = 4)) + } + } + + private fun historyRow(entry: CaptureHistoryStore.Entry): View = with(activity) { + val tag = Labels.payloadTag(this, entry.payloadType) + val mark = icon(R.drawable.ic_circle_outline, R.color.outline_strong, sizeDp = 22) + val row = AccessibleRow(this) + fun refresh() { + val isSelected = selecting && entry.id in selectedIds + row.isSelected = isSelected + mark.setImageResource(if (isSelected) R.drawable.ic_check_circle else R.drawable.ic_circle_outline) + mark.imageTintList = ColorStateList.valueOf( + getColor(if (isSelected) R.color.accent_text else R.color.outline_strong), + ) + row.contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { isSelected }, + entry.title, + getString( + R.string.payload_tag_description, + tag, + entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), + ), + ).joinToString(". ") + } + row.apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(52) + setPadding(dp(12), dp(4), dp(8), dp(4)) + background = selectableRect() + if (selecting) addView(mark, lp(22, 22, end = 12)) + addView(text(entry.title, TextRole.BODY) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, lp(width = 0, weight = 1f)) + addView(badge(tag).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) + if (!selecting) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + refresh() + setOnClickListener { + if (selecting) { + // Update in place so accessibility focus stays on this row. + if (!selectedIds.add(entry.id)) selectedIds.remove(entry.id) + refresh() + updateSelectionBar() + } else { + open(entry) + } + } + setOnLongClickListener { + selectedIds.add(entry.id) + if (selecting) { + refresh() + updateSelectionBar() + } else { + setSelecting(true) + } + true + } + } + } + + private fun selectionActions(): View = with(activity) { + row { + selectionButtons.clear() + selectionCount = text(null, TextRole.LABEL) + addView(selectionCount, lp(width = 0, weight = 1f, start = 4)) + if (!showingSaved) { + addView(button(getString(R.string.save_selected_history_short), Controls.Role.SECONDARY, R.drawable.ic_save) { + saveSelected() + }.also(selectionButtons::add), lp(WRAP, WRAP, end = 8)) + } + addView(button(getString(R.string.delete), Controls.Role.DESTRUCTIVE_TEXT, R.drawable.ic_delete) { + confirmDeleteSelected() + }.also(selectionButtons::add), lp(WRAP, WRAP)) + updateSelectionBar() + } + } + + private fun updateSelectionBar() { + selectionCount?.text = activity.resources.getQuantityString(R.plurals.selected_count, selectedIds.size, selectedIds.size) + selectionButtons.forEach { it.isEnabled = selectedIds.isNotEmpty() } + } + + private fun setSelecting(enabled: Boolean) { + selecting = enabled + if (!enabled) selectedIds.clear() + exitSelection.isEnabled = enabled + render() + } + + private fun open(entry: CaptureHistoryStore.Entry) { + activity.startActivity( + Intent(activity, PacketHistoryActivity::class.java) + .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) + .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) + .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, showingSaved) + .putExtra(PacketHistoryActivity.EXTRA_STORAGE_ID, scope?.storageId), + ) + } + + private fun saveSelected() { + val source = historyStore ?: return + val target = savedStore ?: return + if (work.isBusy(WRITE)) return + val ids = selectedIds.toList() + work.run(key = WRITE, task = { target.saveFrom(source, ids) }) { result -> + val saveResult = result.getOrNull() + val resources = activity.resources + Feedback.show( + activity, + when { + saveResult == null -> activity.getString(R.string.status_save_history_failed) + saveResult.saved > 0 && saveResult.limitReached -> resources.getQuantityString( + R.plurals.status_saved_history_at_limit, + saveResult.saved, + saveResult.saved, + SavedHistoryStore.MAX_ENTRIES, + ) + saveResult.saved > 0 -> + resources.getQuantityString(R.plurals.status_saved_history, saveResult.saved, saveResult.saved) + saveResult.limitReached -> activity.getString(R.string.status_saved_history_limit, SavedHistoryStore.MAX_ENTRIES) + else -> activity.getString(R.string.status_history_already_saved) + }, + ) + setSelecting(false) + load() + } + } + + private fun confirmDeleteSelected() { + val ids = selectedIds.toList() + val fromSaved = showingSaved + Dialogs.confirm( + activity, + activity.getString(R.string.delete_history_title), + activity.resources.getQuantityString(R.plurals.delete_history_message, ids.size, ids.size), + activity.getString(R.string.delete), + destructive = true, + ) confirm@{ + if (work.isBusy(WRITE)) return@confirm + work.run( + key = WRITE, + task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }, + ) { result -> + val deleted = result.getOrDefault(0) + Feedback.show( + activity, + activity.resources.getQuantityString( + if (fromSaved) R.plurals.status_deleted_saved_history else R.plurals.status_deleted_history, + deleted, + deleted, + ), + ) + setSelecting(false) + load() + } + } + } + + private companion object { + const val LOAD = "history" + const val WRITE = "history_write" + const val RECENT = "recent" + const val SAVED = "saved" + const val COLLAPSED_COUNT = 20 + const val STATE_SHOWING_SAVED = "home.showing_saved" + const val STATE_SHOW_ALL = "home.show_all" + const val STATE_SELECTING = "home.selecting" + const val STATE_SELECTED = "home.selected" + } +} diff --git a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt deleted file mode 100644 index f9e3c4e..0000000 --- a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.gf2log.app - -import android.app.Activity -import android.content.Context -import android.os.Bundle - -abstract class LocalizedActivity : Activity() { - private var attachedLanguage = LanguagePreferences.DEFAULT_LANGUAGE - private var attachedTheme = ThemePreferences.SYSTEM - - override fun attachBaseContext(newBase: Context) { - attachedLanguage = LanguagePreferences.get(newBase) - attachedTheme = preferredTheme(newBase) - val localized = LanguagePreferences.wrap(newBase, attachedLanguage) - super.attachBaseContext(ThemePreferences.wrap(localized, attachedTheme)) - } - - /** Allows a purpose-built surface to use a stable presentation theme. */ - protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context) - - protected open fun supportsPendingPlatoonAdmission(): Boolean = true - - override fun onResume() { - super.onResume() - if ( - LanguagePreferences.get(this) != attachedLanguage || - preferredTheme(this) != attachedTheme - ) { - recreate() - return - } - if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { - PendingPlatoonAdmissionPrompt.showIfNeeded(this) - } - } - - override fun onPostCreate(savedInstanceState: Bundle?) { - super.onPostCreate(savedInstanceState) - ModernUi.prepareContent(window.decorView) - // Android restores parts of the view hierarchy after onPostCreate during - // locale/theme recreation. Reapply presentation roles on the next frame so - // restored platform drawables cannot replace the app's semantic controls. - window.decorView.post { - if (!isFinishing) ModernUi.prepareContent(window.decorView) - } - } -} diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 817b2e3..e430628 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -1,1218 +1,306 @@ package dev.gf2log.app import android.Manifest -import android.app.Activity -import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager -import android.database.Cursor -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.GradientDrawable -import android.net.Uri import android.net.VpnService import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.OpenableColumns import android.view.View -import android.view.ViewGroup -import android.view.Gravity -import android.widget.Button -import android.widget.CheckBox -import android.widget.ImageButton +import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout -import android.widget.PopupWindow -import android.widget.ScrollView +import android.widget.ProgressBar import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.capture.CaptureSnapshot import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService -import dev.gf2log.app.history.CaptureHistoryStore -import dev.gf2log.app.history.SavedHistoryStore -import dev.gf2log.app.management.PlatoonBackupManager +import dev.gf2log.app.capture.format import dev.gf2log.app.management.CsvImportCheckpointManager -import dev.gf2log.app.management.CsvImportPreviewAnalyzer import dev.gf2log.app.management.PlatoonCsvImportStore -import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.PlatoonProfileRegistry -import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.management.BackupFileName -import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.management.SnapshotComparison +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.CaptureGuard +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.captureActive +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.setShownInGroup +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.text import dev.gf2log.protocol.GuildMembersCsv -import dev.gf2log.protocol.Gfl2PayloadDecoder -import dev.gf2log.protocol.PayloadCatalog -import java.io.File -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.concurrent.Executors +import java.time.ZoneId -class MainActivity : LocalizedActivity() { - private lateinit var profileBinding: ActivePlatoonScopeBinding - private lateinit var statusText: TextView - private lateinit var captureStateText: TextView - private lateinit var captureStatusText: TextView - private lateinit var prepareCaptureButton: Button - private lateinit var captureOnceButton: Button - private lateinit var stopCaptureButton: Button - private lateinit var guidedCaptureText: TextView - private lateinit var historyContainer: LinearLayout - private lateinit var historyActions: LinearLayout - private lateinit var savedHistoryContainer: LinearLayout - private lateinit var savedHistoryActions: LinearLayout - private lateinit var historyStore: CaptureHistoryStore - private lateinit var savedHistoryStore: SavedHistoryStore - private val selectedHistoryIds = linkedSetOf() - private val selectedSavedHistoryIds = linkedSetOf() - private val statusHandler = Handler(Looper.getMainLooper()) - private val fileIoExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2FileIo") - } - private val refreshStatus = object : Runnable { - override fun run() { - renderCaptureStatus() - renderGuidedCaptureProgress() - statusHandler.postDelayed(this, STATUS_REFRESH_MILLIS) - } - } - private var pendingExport: File? = null - private var captureOnceRequested = false - private var pendingCsvImport: PendingCsvImport? = null - private var pendingCsvPickerStorageId: String? = null +/** + * Home: capture state and actions first, then the selected Platoon, data tools, and recent + * parsed-packet evidence. Capture state is observed through [CaptureStatus] listeners. + */ +class MainActivity : BaseActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pendingCsvPickerStorageId = savedInstanceState?.getString(STATE_CSV_TARGET) - profileBinding = ActivePlatoonScopeBinding(this) - if (!OnboardingPreferences.isCompleted(this)) { - startActivity(Intent(this, OnboardingActivity::class.java)) - finish() - return - } - val historyRoot = profileBinding.scope?.rootDirectory(this) - ?: File(cacheDir, "no-profile-history") - historyStore = CaptureHistoryStore(File(historyRoot, CaptureHistoryStore.HISTORY_DIRECTORY)) - savedHistoryStore = SavedHistoryStore(File(historyRoot, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) - setContentView(buildContentView()) - requestNotificationPermissionIfNeeded() - intent.getStringExtra(EXTRA_LAUNCH_CSV_PICKER) - ?.takeIf { it == profileBinding.scope?.storageId } - ?.let { storageId -> - intent.removeExtra(EXTRA_LAUNCH_CSV_PICKER) - pendingCsvPickerStorageId = storageId - window.decorView.post(::selectPlatoonCsvFiles) - } - } + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2Home") + private lateinit var csvImport: CsvImportFlow + private lateinit var packetHistory: HomePacketHistory - override fun onSaveInstanceState(outState: Bundle) { - pendingCsvPickerStorageId?.let { outState.putString(STATE_CSV_TARGET, it) } - super.onSaveInstanceState(outState) - } + private lateinit var capturePanel: HomeCapturePanel + private lateinit var undoRow: View + private lateinit var progressBanner: LinearLayout + private lateinit var progressText: TextView + private lateinit var rosterSummary: LinearLayout - override fun onResume() { - super.onResume() - if (!profileBinding.isCurrent(this)) { - recreate() - return - } - if (!::captureStatusText.isInitialized) return - renderCaptureStatus() - refreshHistory() - statusHandler.postDelayed(refreshStatus, STATUS_REFRESH_MILLIS) - } + private var captureOnceRequested = false - override fun onPause() { - statusHandler.removeCallbacks(refreshStatus) - super.onPause() - } + /** A start is waiting on the permission or VPN consent screens; further taps are ignored. */ + private var startPending = false - override fun onDestroy() { - fileIoExecutor.shutdownNow() - super.onDestroy() - } + private val statusListener: (CaptureSnapshot) -> Unit = { capturePanel.render(it) } - @Deprecated("Uses the platform VPN consent activity without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - when { - requestCode == REQUEST_VPN && resultCode == RESULT_OK -> startCaptureService() - requestCode == REQUEST_EXPORT -> { - val source = pendingExport - pendingExport = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || source == null) return - runFileOperation( - successMessage = { getString(R.string.status_exported, source.name) }, - failureMessage = { getString(R.string.status_export_failed) }, - ) { - val output = TrustedExportDestination.openOutputStream( - contentResolver, - destination, - ) - ?: error("Document provider did not open an output stream") - output.writer(Charsets.UTF_8).use { writer -> - val snapshot = requireNotNull( - GuildMembersCsv.parse(source.readText(Charsets.UTF_8)), - ) { "Stored Platoon CSV is invalid" } - writer.write(GuildMembersCsv.formatForSpreadsheet(snapshot)) - } - } - } - requestCode == REQUEST_BACKUP_EXPORT -> { - val destination = data?.data - if (resultCode != RESULT_OK || destination == null) return - val scope = profileBinding.scope ?: return - runFileOperation( - successMessage = { getString(R.string.status_backup_exported) }, - failureMessage = { getString(R.string.status_backup_failed) }, - ) { - val output = TrustedExportDestination.openOutputStream( - contentResolver, - destination, - ) ?: error("Document provider did not open an output stream") - output.use { PlatoonBackupManager(this, scope).export(it) } - } - } - requestCode == REQUEST_BACKUP_IMPORT -> { - val source = data?.data - if (resultCode != RESULT_OK || source == null) return - if (!hasBackupExtension(source)) { - statusText.text = getString(R.string.invalid_platoon_backup) - return - } - runFileOperation( - successMessage = { getString(R.string.status_backup_restored) }, - failureMessage = { getString(R.string.status_backup_failed) }, - onSuccess = { recreate() }, - ) { - val input = TrustedImportSource.openInputStream(contentResolver, source) - ?: error("Document provider did not open an input stream") - input.use { PlatoonBackupManager.restoreSelected(this, it, complete = false) } - } - } - requestCode == REQUEST_CSV_IMPORT -> { - if (resultCode != RESULT_OK) { - pendingCsvPickerStorageId = null - return - } - val sources = buildList { - data?.clipData?.let { clip -> - repeat(clip.itemCount) { index -> add(clip.getItemAt(index).uri) } - } - data?.data?.let(::add) - }.distinct() - if (sources.isNotEmpty()) { - val target = resolveCsvImportTarget(pendingCsvPickerStorageId) - if (target == null) { - pendingCsvPickerStorageId = null - statusText.text = getString(R.string.csv_import_target_changed) - } else { - preparePlatoonCsvSources(sources, target) - } - } else { - pendingCsvPickerStorageId = null - } - } - } + private val vpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + startPending = false + if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.markStopped() } - private fun runFileOperation( - successMessage: () -> String, - failureMessage: () -> String, - onSuccess: () -> Unit = {}, - operation: () -> Unit, - ) { - fileIoExecutor.execute { - val succeeded = runCatching(operation).isSuccess - statusHandler.post { - if (!isFinishing && !isDestroyed) { - statusText.text = if (succeeded) successMessage() else failureMessage() - if (succeeded) onSuccess() - } - } - } + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + requestVpnAndStart() } - private fun buildContentView(): View { - val spacing = dp(16) - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, dp(10), spacing, dp(20)) - - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(android.widget.ImageView(context).apply { - setImageResource(R.mipmap.ic_launcher) - scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE - }, LinearLayout.LayoutParams(dp(40), dp(40)).apply { - marginEnd = dp(10) - }) - addView(TextView(context).apply { - text = getString(R.string.app_name) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_settings) - contentDescription = getString(R.string.open_options) - useModernIconStyle() - setPadding(dp(10), dp(10), dp(10), dp(10)) - setOnClickListener { - startActivity(Intent(this@MainActivity, OptionsActivity::class.java)) - } - }, LinearLayout.LayoutParams(dp(48), dp(48))) - }, matchWidth()) - - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(10), dp(10), dp(10), dp(10)) - background = ModernUi.panelBackground(context).apply { - setStroke(dp(1), getColor(R.color.outline)) + private val exportRoster = registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> + val scope = profileBinding.scope ?: return@registerForActivityResult + if (uri == null) return@registerForActivityResult + work.run( + task = { + val latest = requireNotNull(PlatoonCsvImportStore.latestRetainedFile(scope.retainedCsvDirectory(this))) + val snapshot = requireNotNull(GuildMembersCsv.parse(latest.readText(Charsets.UTF_8))) { + "Stored Platoon CSV is invalid" } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.TOP - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(View(context).apply { - background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(getColor(R.color.accent)) - } - }, LinearLayout.LayoutParams(dp(8), dp(8)).apply { - marginEnd = dp(8) - }) - addView(TextView(context).apply { - text = getString(R.string.capture_status_label) - textSize = 13f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }) - }, matchWidth()) - captureStateText = TextView(context).apply { - textSize = 22f - setTextColor(getColor(R.color.success_text)) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(4), 0, 0) - } - addView(captureStateText, matchWidth()) - captureStatusText = TextView(context).apply { - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - } - addView(captureStatusText, matchWidth()) - }, LinearLayout.LayoutParams(dp(124), ViewGroup.LayoutParams.WRAP_CONTENT).apply { - marginEnd = dp(8) - }) - addView( - PlatoonProfileSelector.controls( - this@MainActivity, - compact = true, - showManageButton = false, - ), - LinearLayout.LayoutParams( - 0, - ViewGroup.LayoutParams.WRAP_CONTENT, - 1f, - ).apply { topMargin = dp(18) }, - ) - }, matchWidth()) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.BOTTOM - addView(TextView(context).apply { - text = getString(R.string.capture_target) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(4)) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_info_outline) - contentDescription = getString(R.string.target_package_info) - useModernIconStyle() - setOnClickListener(::showTargetPackageInfo) - }, LinearLayout.LayoutParams(dp(48), dp(48))) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(48), - )) - addView(fixedTargetField(SupportedGamePackages.HAOPLAY), matchWidth()) - addView( - fixedTargetField(SupportedGamePackages.DARKWINTER), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - prepareCaptureButton = Button(context).apply { - text = getString(R.string.prepare_capture) - useCaptureActionStyle() - allowCompactMultilineLabel() - setOnClickListener { requestVpnAndStart(captureOnce = false) } - } - addView(prepareCaptureButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { - marginEnd = dp(3) - }) - captureOnceButton = Button(context).apply { - text = getString(R.string.capture_one_roster) - usePrimaryActionStyle() - allowCompactMultilineLabel() - setOnClickListener { requestVpnAndStart(captureOnce = true) } - } - addView(captureOnceButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { - marginStart = dp(3) - marginEnd = dp(3) - }) - stopCaptureButton = Button(context).apply { - text = getString(R.string.stop_capture) - useDestructiveActionStyle() - setOnClickListener { stopCaptureService() } - } - addView(stopCaptureButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { - marginStart = dp(3) - }) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(4) }) - guidedCaptureText = TextView(context).apply { - visibility = View.GONE - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(4), 0, 0) + DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { + it.write(GuildMembersCsv.formatForSpreadsheet(snapshot)) } - addView(guidedCaptureText, matchWidth()) - renderCaptureStatus() - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - - statusText = TextView(context).apply { - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(dp(4), dp(6), dp(4), 0) - } - addView(statusText, matchWidth()) - - addView(sectionLabel(getString(R.string.data_tools), topPadding = dp(12)), matchWidth()) - listOf( - ModernUi.listRow(context, getString(R.string.import_platoon_csv), icon = R.drawable.ic_edit) { - showCsvImportSelector() - }, - ModernUi.listRow(context, getString(R.string.export_platoon_backup), icon = R.drawable.ic_save) { - if (requireActiveScope() != null) exportPlatoonBackup() - }, - ModernUi.listRow(context, getString(R.string.undo_last_csv_import), icon = R.drawable.ic_arrow_back) { - if (requireActiveScope() != null) confirmUndoLastCsvImport() - }, - ModernUi.listRow(context, getString(R.string.import_platoon_backup), icon = R.drawable.ic_save) { - confirmImportPlatoonBackup() - }, - ).forEach { row -> - addView(row, matchWidth()) - } - addView(sectionLabel(getString(R.string.recent_packets, CaptureHistoryStore.MAX_ENTRIES)), matchWidth()) - historyContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - } - addView(historyContainer, matchWidth()) - historyActions = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(Button(context).apply { - text = getString(R.string.delete_selected_history) - useDestructiveTextActionStyle() - setOnClickListener { deleteSelectedHistory() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.save_selected_history) - useSecondaryActionStyle() - setOnClickListener { saveSelectedHistory() } - }, matchWidth()) - } - addView(historyActions, matchWidth()) - - addView(sectionLabel(getString(R.string.saved_packets, SavedHistoryStore.MAX_ENTRIES)), matchWidth()) - savedHistoryContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - } - addView(savedHistoryContainer, matchWidth()) - savedHistoryActions = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(Button(context).apply { - text = getString(R.string.delete_selected_saved_history) - useDestructiveTextActionStyle() - setOnClickListener { deleteSelectedSavedHistory() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.export_latest_platoon_csv) - useSecondaryActionStyle() - setOnClickListener { exportLatestPlatoonCsv() } - }, matchWidth()) - } - addView(savedHistoryActions, matchWidth()) - } - val scroll = ScrollView(this).apply { addView(container, matchWidth()) } - return PrimaryNavigation.wrap(this, scroll, PrimaryNavigation.Destination.HOME) - } - - private fun sectionLabel(textValue: CharSequence, topPadding: Int = dp(18)) = TextView(this).apply { - text = textValue - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(dp(2), topPadding, 0, dp(7)) - } - - private fun renderCaptureStatus() { - if (!::captureStateText.isInitialized || !::captureStatusText.isInitialized) return - val starting = CaptureStatus.isStarting - val running = CaptureStatus.isRunning - captureStateText.text = getString( - when { - starting -> R.string.status_preparing - running -> R.string.capture_running - else -> R.string.capture_ready }, - ) - val status = CaptureStatus.read() - val detail = when { - status.startsWith("Capturing only ") -> "" - status == "Capture is stopped" -> getString(R.string.capture_stopped_detail) - status == "Preparing capture" -> getString(R.string.status_preparing) - else -> status + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.roster_csv_exported else R.string.status_export_failed) } - captureStatusText.text = detail - captureStatusText.visibility = if (detail.isBlank()) View.GONE else View.VISIBLE - captureStateText.contentDescription = captureStateText.text - captureStatusText.contentDescription = captureStatusText.text - val busy = starting || running - if (::prepareCaptureButton.isInitialized) prepareCaptureButton.isEnabled = !busy - if (::captureOnceButton.isInitialized) captureOnceButton.isEnabled = !busy - if (::stopCaptureButton.isInitialized) stopCaptureButton.isEnabled = busy } - // Function Name: renderGuidedCaptureProgress - // Description: - // - Renders the four required Platoon payloads as a live checklist. - // - Keeps display logic independent from capture-service status messages. - // Parameters: - // - None. - // Returns: - // - Unit after updating the checklist visibility and text. - private fun renderGuidedCaptureProgress() { - val progress = CaptureStatus.readGuidedProgress() - guidedCaptureText.visibility = if (progress == null) View.GONE else View.VISIBLE - if (progress == null) return - fun mark(payloadType: Int): String = - if (payloadType in progress.capturedPayloadTypes) "\u2713" else "\u25cb" - guidedCaptureText.text = getString( - R.string.guided_capture_checklist, - mark(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE), - mark(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS), - mark(Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY), - mark(Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES), - ) - } - - private fun showTargetPackageInfo(anchor: View) { - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(14), dp(16), dp(16)) - background = ModernUi.panelBackground(context).apply { - setStroke(dp(1), getColor(R.color.outline_strong)) - } - addView(TextView(context).apply { - text = getString(R.string.target_package_info_title) - textSize = 16f - setTextColor(getColor(R.color.text_primary)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.target_package_info_message) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setLineSpacing(0f, 1.15f) - setPadding(0, dp(8), 0, 0) - }, matchWidth()) - } - val popupWidth = minOf(dp(360), resources.displayMetrics.widthPixels - dp(32)) - PopupWindow( - content, - popupWidth, - ViewGroup.LayoutParams.WRAP_CONTENT, - true, - ).apply { - setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - isOutsideTouchable = true - elevation = dp(6).toFloat() - showAsDropDown(anchor, anchor.width - popupWidth, dp(4)) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!OnboardingPreferences.isCompleted(this)) { + startActivity(Intent(this, OnboardingActivity::class.java)) + finish() + return } - } - - private fun fixedTargetField(packageId: String) = TextView(this).apply { - text = packageId - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(12), 0, dp(12), 0) - minHeight = dp(38) - isClickable = false - isFocusable = false - background = ModernUi.panelBackground(context).apply { - setStroke(dp(1), getColor(R.color.outline)) + profileBinding = ActivePlatoonScopeBinding(this) + packetHistory = HomePacketHistory(this, work, profileBinding.scope) + packetHistory.restoreState(savedInstanceState) + csvImport = CsvImportFlow(this, work, { profileBinding.scope }, ::showProgress) + csvImport.restoreState(savedInstanceState) + captureOnceRequested = savedInstanceState?.getBoolean(STATE_CAPTURE_ONCE) == true + screen = Screen(this, PrimaryNavigation.Destination.HOME) + screen.topBar + .setLeading(ImageView(this).apply { + setImageResource(R.mipmap.ic_launcher) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + layoutParams = FrameLayout.LayoutParams(dp(36), dp(36)) + }) + .setTitle(getString(R.string.app_name)) + .setActions(listOf(BarAction(R.drawable.ic_settings, getString(R.string.open_options)) { + startActivity(Intent(this, OptionsActivity::class.java)) + })) + screen.setBody(Screen.scrollingBody(this, buildContent())) + } + + override fun onStart() { + super.onStart() + if (::capturePanel.isInitialized) CaptureStatus.addListener(statusListener) + } + + override fun onStop() { + CaptureStatus.removeListener(statusListener) + super.onStop() + } + + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) { + recreate() + return } + capturePanel.renderClients() + packetHistory.load() + loadRosterSummary() + loadUndoAvailability() + csvImport.onScreenReady() } - @Suppress("DEPRECATION") - private fun requestVpnAndStart(captureOnce: Boolean) { - captureOnceRequested = captureOnce - val permissionIntent = VpnService.prepare(this) - if (permissionIntent == null) { - startCaptureService() - } else { - startActivityForResult(permissionIntent, REQUEST_VPN) - } + override fun onSaveInstanceState(outState: Bundle) { + if (::csvImport.isInitialized) csvImport.saveState(outState) + if (::packetHistory.isInitialized) packetHistory.saveState(outState) + outState.putBoolean(STATE_CAPTURE_ONCE, captureOnceRequested) + super.onSaveInstanceState(outState) } - private fun startCaptureService() { - CaptureStatus.markStarting() - renderCaptureStatus() - val intent = Intent(this, CaptureVpnService::class.java) - .setAction(CaptureVpnService.ACTION_START) - .putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, captureOnceRequested) - startForegroundService(intent) - captureOnceRequested = false + private fun buildContent(): LinearLayout = column { + progressText = text(null, TextRole.SECONDARY) { setTextColor(getColor(R.color.accent_text)) } + progressBanner = panel(emphasized = true, padding = 12) { + visibility = View.GONE + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + addView(row { + addView(ProgressBar(context).apply { + isIndeterminate = true + indeterminateTintList = android.content.res.ColorStateList.valueOf(getColor(R.color.accent)) + }, lp(20, 20, end = 12)) + addView(progressText, lp(width = 0, weight = 1f)) + }, lp()) + } + addView(progressBanner, lp(top = 4, bottom = 8)) + capturePanel = HomeCapturePanel(this@MainActivity, ::beginCapture) { CaptureGuard.stop(this@MainActivity) } + addView(capturePanel.view, lp(top = 4)) + addView(sectionHeader(getString(R.string.active_platoon_section)), lp()) + addView(PlatoonProfileSelector.card(this@MainActivity), lp()) + rosterSummary = column { visibility = View.GONE } + addView(rosterSummary, lp(top = 8)) + addView(sectionHeader(getString(R.string.data_tools)), lp()) + addView(group(listOf( + navigationRow(getString(R.string.import_platoon_csv), getString(R.string.import_platoon_csv_detail), R.drawable.ic_upload) { + csvImport.start() + }, + navigationRow(getString(R.string.undo_last_csv_import), getString(R.string.undo_last_csv_import_detail), R.drawable.ic_undo) { + csvImport.undo() + }.also { undoRow = it }, + navigationRow( + getString(R.string.export_latest_platoon_csv), + getString(R.string.export_latest_platoon_csv_detail), + R.drawable.ic_download, + ) { exportLatestRoster() }, + navigationRow(getString(R.string.backup_and_restore), getString(R.string.backup_and_restore_detail), R.drawable.ic_storage) { + startActivity(Intent(this@MainActivity, OptionsActivity::class.java)) + }, + )), lp()) + addView(packetHistory.view, lp()) + undoRow.setShownInGroup(false) } - private fun stopCaptureService() { - val intent = Intent(this, CaptureVpnService::class.java) - .setAction(CaptureVpnService.ACTION_STOP) - startService(intent) - CaptureStatus.markStopped() - renderCaptureStatus() - } + // region Capture - private fun requestNotificationPermissionIfNeeded() { + private fun beginCapture(once: Boolean) { + if (startPending || captureActive) return + startPending = true + captureOnceRequested = once if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_NOTIFICATIONS) - } - } - - private fun requireActiveScope(): PlatoonStorageScope? = profileBinding.scope.also { scope -> - if (scope == null) { - showNoPlatoonMessage() - } - } - - private fun showNoPlatoonMessage() { - TransientMessage.show(this, R.string.no_platoon_detected_detail, android.widget.Toast.LENGTH_LONG) - } - - private fun showCsvImportSelector() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_csv_import) - return - } - PlatoonCsvImportPrompt.show(this, ::beginCsvImport) - } - - private fun beginCsvImport(profile: PlatoonProfile) { - val registry = PlatoonProfileRegistry(this) - if (!registry.setActive(profile.storageId)) { - statusText.text = getString(R.string.csv_import_target_changed) - return - } - pendingCsvPickerStorageId = profile.storageId - if (profileBinding.scope?.storageId == profile.storageId) { - selectPlatoonCsvFiles() + // Capture works without it, but the foreground notification is the only stop control + // outside the app, so ask at the moment it becomes relevant. + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } else { - intent.putExtra(EXTRA_LAUNCH_CSV_PICKER, profile.storageId) - recreate() - } - } - - private fun resolveCsvImportTarget(storageId: String?): CsvImportTarget? { - val scope = storageId?.let(::PlatoonStorageScope) ?: return null - val registry = PlatoonProfileRegistry(this) - val activeProfile = registry.active() - if ( - activeProfile == null || - activeProfile.storageId != scope.storageId || - activeProfile.legacy || - profileBinding.scope != scope - ) { - statusText.text = getString(R.string.csv_import_target_changed) - return null + requestVpnAndStart() } - return CsvImportTarget(scope, activeProfile) } - private fun openScopedActivity(activityClass: Class) { - if (requireActiveScope() != null) startActivity(Intent(this, activityClass)) + private fun requestVpnAndStart() { + val consent = VpnService.prepare(this) + if (consent == null) startCaptureService() else vpnConsent.launch(consent) } - @Suppress("DEPRECATION") - private fun exportPlatoonBackup() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_backup) - return - } - val title = "GF2logger-platoon-${BACKUP_TIME.format(LocalDateTime.now())}." + - PlatoonBackupManager.FILE_EXTENSION - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(PlatoonBackupManager.MIME_TYPE) - .putExtra(Intent.EXTRA_TITLE, title) - startActivityForResult(intent, REQUEST_BACKUP_EXPORT) - } - - private fun confirmImportPlatoonBackup() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_backup) - return - } - AlertDialog.Builder(this) - .setTitle(R.string.import_platoon_backup) - .setMessage(R.string.import_backup_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.import_platoon_backup) { _, _ -> importPlatoonBackup() } - .show() + private fun startCaptureService() { + CaptureStatus.markStarting() + startForegroundService( + Intent(this, CaptureVpnService::class.java) + .setAction(CaptureVpnService.ACTION_START) + .putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, captureOnceRequested), + ) + captureOnceRequested = false + startPending = false } - @Suppress("DEPRECATION") - // Function Name: selectPlatoonCsvFiles - // Description: - // - Opens Android's trusted document picker for one or more roster CSV files. - // Parameters: - // - None. - // Returns: - // - Unit after dispatching the picker activity. - private fun selectPlatoonCsvFiles() { - if (resolveCsvImportTarget(pendingCsvPickerStorageId) == null) { - pendingCsvPickerStorageId = null - return - } - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/csv", "text/comma-separated-values")) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - startActivityForResult(intent, REQUEST_CSV_IMPORT) - } + // endregion + // region Data tools - // Function Name: preparePlatoonCsvSources - // Description: - // - Reconciles crash-left retained evidence before classifying selected source identities. - // - Validates a bounded selection without retaining any newly selected source. - // - Computes a user-visible impact preview from the recovered repository state. - // Parameters: - // - sources: Distinct document-provider URIs returned by the picker. - // Returns: - // - Unit after scheduling validation and preview display. - private fun preparePlatoonCsvSources(sources: List, target: CsvImportTarget) { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_csv_import) - return - } - val storageScope = target.storageScope - statusText.text = getString(R.string.csv_import_preparing_preview) - fileIoExecutor.execute { - val result = runCatching { - // Recover an interrupted prior import before reading the preview baseline. - CsvImportCheckpointManager(this, storageScope) - require(sources.size <= MAX_CSV_IMPORT_FILES) { - "Too many Platoon CSV files were selected" - } - val directory = storageScope.retainedCsvDirectory(this) - val store = PlatoonCsvImportStore(directory) - val selected = ArrayList(sources.size) - var selectedBytes = 0L - sources.forEach { source -> - val input = TrustedImportSource.openInputStream(contentResolver, source) - ?: error("Document provider did not open an input stream") - val prepared = input.use(store::prepare) - selectedBytes += prepared.byteCount - require(selectedBytes <= MAX_CSV_IMPORT_BYTES) { - "Selected Platoon CSV files exceed the total import size limit" - } - selected += prepared - } - val unique = selected.distinctBy(PlatoonCsvImportStore.PreparedImport::fileName) - val repository = PlatoonRepository(this, storageScope) - repository.reconcileRetainedCsvFiles(directory) - val duplicateNames = CsvImportPreviewAnalyzer.duplicateFileNames( - prepared = unique, - representedSourceFiles = repository.representedSnapshotSources(), - ) - val analyzed = CsvImportPreviewAnalyzer.analyze( - prepared = unique, - duplicateFileNames = duplicateNames, - existingMembers = repository.listMemberStatuses(), - latestSnapshot = repository.listSnapshots(limit = 1).firstOrNull(), - ) - val preview = analyzed.copy( - validatedFiles = selected.size, - duplicateFiles = analyzed.duplicateFiles + selected.size - unique.size, - totalBytes = selectedBytes, - ) - PendingCsvImport( - storageScope = storageScope, - destinationProfile = target.profile, - prepared = unique, - duplicateFileNames = duplicateNames, - preview = preview, - ) - } - statusHandler.post { - if (isFinishing || isDestroyed) return@post - result.fold( - onSuccess = { - pendingCsvImport = it - showCsvImportPreview(it) - }, - onFailure = { - pendingCsvImport = null - pendingCsvPickerStorageId = null - statusText.text = getString(R.string.status_platoon_csv_import_failed) - }, - ) - } + private fun showProgress(message: CharSequence?) { + progressBanner.visibility = if (message == null) View.GONE else View.VISIBLE + progressText.text = message + if (message == null) { + // An import or undo just finished: the roster and the undo checkpoint may have changed. + loadRosterSummary() + loadUndoAvailability() } } - private fun showCsvImportPreview(pending: PendingCsvImport) { - val preview = pending.preview - statusText.text = "" - val range = if (preview.firstCapture == null || preview.lastCapture == null) { - getString(R.string.none) - } else { - getString( - R.string.csv_import_capture_range, - preview.firstCapture.toString(), - preview.lastCapture.toString(), - ) + /** The undo row only appears while a CSV import checkpoint exists. */ + private fun loadUndoAvailability() { + val scope = profileBinding.scope ?: return + work.run(key = UNDO, task = { CsvImportCheckpointManager(this, scope).canUndo() }) { result -> + undoRow.setShownInGroup(result.getOrDefault(false)) } - AlertDialog.Builder(this) - .setTitle(R.string.csv_import_preview_title) - .setMessage( - getString( - R.string.csv_import_destination, - pending.destinationProfile.platoonName, - pending.destinationProfile.platoonId, - pending.destinationProfile.client.displayName, - csvImportRegionLabel(pending.destinationProfile.serverRegion), - ) + "\n\n" + getString( - R.string.csv_import_preview_summary, - preview.validatedFiles, - preview.duplicateFiles, - preview.historicalFiles, - preview.uniqueMembers, - preview.newMembers, - preview.nameDifferences, - preview.potentialJoins, - preview.potentialWithdrawals, - preview.totalBytes / 1024, - range, - ), - ) - .setNegativeButton(android.R.string.cancel) { _, _ -> - pendingCsvImport = null - pendingCsvPickerStorageId = null - } - .setPositiveButton(R.string.import_platoon_csv) { _, _ -> - applyPreparedCsvImport(pending) - } - .setOnCancelListener { - pendingCsvImport = null - pendingCsvPickerStorageId = null - } - .show() } - private fun csvImportRegionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun applyPreparedCsvImport(pending: PendingCsvImport) { - val registry = PlatoonProfileRegistry(this) - val targetStillExists = registry.find(pending.storageScope.storageId) - ?.let { - it.client == pending.destinationProfile.client && - it.platoonId == pending.destinationProfile.platoonId - } - ?: false - val targetStillSelected = registry.active()?.storageId == pending.storageScope.storageId - if ( - pendingCsvImport !== pending || - CaptureStatus.isRunning || - profileBinding.scope != pending.storageScope || - !targetStillExists || - !targetStillSelected - ) { - pendingCsvImport = null - pendingCsvPickerStorageId = null - statusText.text = getString( - if (CaptureStatus.isRunning) { - R.string.stop_capture_before_csv_import - } else { - R.string.csv_import_target_changed - }, - ) + private fun exportLatestRoster() { + val scope = profileBinding.scope ?: run { + Feedback.show(this, R.string.no_platoon_detected_detail, long = true) return } - pendingCsvImport = null - statusText.text = getString(R.string.csv_import_applying) - fileIoExecutor.execute { - val directory = pending.storageScope.retainedCsvDirectory(this) - val store = PlatoonCsvImportStore(directory) - val checkpoint = CsvImportCheckpointManager(this, pending.storageScope) - var checkpointCreated = false - val result = runCatching { - val plannedNames = pending.prepared - .filterNot { it.fileName in pending.duplicateFileNames } - .mapTo(mutableSetOf()) { it.fileName } - checkpoint.create(plannedNames) - checkpointCreated = true - var retained = 0 - var duplicates = 0 - pending.prepared.forEach { prepared -> - store.retain(prepared).also { - if (it.duplicate) duplicates += 1 else retained += 1 - } - } - val imported = PlatoonRepository(this, pending.storageScope) - .reconcileRetainedCsvFiles(directory) - checkpoint.seal() - CsvImportSummary(retained, duplicates, imported) - }.recoverCatching { failure -> - if (checkpointCreated) { - runCatching(checkpoint::rollbackFailedImport).exceptionOrNull()?.let(failure::addSuppressed) - } - throw failure - } - statusHandler.post { - if (isFinishing || isDestroyed) return@post - pendingCsvPickerStorageId = null - statusText.text = result.fold( - onSuccess = { summary -> - getString( - R.string.status_platoon_csv_imported_with_checkpoint, - summary.retained, - summary.imported.imported, - summary.imported.historical, - summary.duplicates + summary.imported.skipped, - ) - }, - onFailure = { getString(R.string.status_platoon_csv_import_failed) }, - ) - } + work.run(task = { PlatoonCsvImportStore.latestRetainedFile(scope.retainedCsvDirectory(this))?.name }) { result -> + val name = result.getOrNull() + if (name == null) Feedback.show(this, R.string.status_no_platoon_csv) else exportRoster.launch(name) } } - private fun confirmUndoLastCsvImport() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_csv_import) - return - } - val scope = requireActiveScope() ?: return - if (!CsvImportCheckpointManager(this, scope).canUndo()) { - TransientMessage.show(this, R.string.no_csv_import_checkpoint) - return - } - AlertDialog.Builder(this) - .setTitle(R.string.undo_last_csv_import) - .setMessage(R.string.undo_last_csv_import_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.undo_last_csv_import) { _, _ -> undoLastCsvImport() } - .show() - } + // endregion - private fun undoLastCsvImport() { - val scope = requireActiveScope() ?: return - statusText.text = getString(R.string.csv_import_undoing) - fileIoExecutor.execute { - val succeeded = runCatching { CsvImportCheckpointManager(this, scope).restore() }.isSuccess - statusHandler.post { - if (isFinishing || isDestroyed) return@post - statusText.text = getString( - if (succeeded) R.string.csv_import_undone else R.string.csv_import_undo_failed, - ) + /** The latest roster change, so a leader sees what the last session captured without leaving Home. */ + private fun loadRosterSummary() { + val scope = profileBinding.scope ?: return + work.run( + key = SUMMARY, + task = { + val snapshots = PlatoonRepository(this, scope).listSnapshots(2) + val latest = snapshots.firstOrNull() + latest?.let { it to snapshots.getOrNull(1)?.let { older -> SnapshotComparison.compare(older, it) } } + }, + ) { result -> + val summary = result.getOrNull() + if (summary == null) { + rosterSummary.visibility = View.GONE + return@run } + val (latest, comparison) = summary + rosterSummary.removeAllViews() + rosterSummary.visibility = View.VISIBLE + val detail = buildList { + add(resources.getQuantityString(R.plurals.member_count, latest.members.size, latest.members.size)) + comparison?.let { + add(getString(R.string.roster_joined_count, it.joined.size)) + add(getString(R.string.roster_withdrew_count, it.left.size)) + } + }.joinToString(" · ") + rosterSummary.addView(group(listOf( + navigationRow( + getString(R.string.latest_roster_title, Labels.format(Labels.DATE_TIME, latest.capturedAt, ZoneId.systemDefault())), + detail, + R.drawable.ic_compare, + enabled = comparison != null, + showChevron = comparison != null, + ) { startActivity(Intent(this, SnapshotComparisonActivity::class.java)) }, + )), lp()) } } - @Suppress("DEPRECATION") - private fun importPlatoonBackup() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra(Intent.EXTRA_MIME_TYPES, BACKUP_MIME_TYPES) - startActivityForResult(intent, REQUEST_BACKUP_IMPORT) - } - - private fun hasBackupExtension(uri: Uri): Boolean { - val name = contentResolver.query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - )?.use { cursor: Cursor -> - if (!cursor.moveToFirst()) null else cursor.getString(0) - } ?: return false - return BackupFileName.isValid(name) - } - - @Suppress("DEPRECATION") - private fun exportLatestPlatoonCsv() { - val directory = (requireActiveScope() ?: return).retainedCsvDirectory(this) - val latest = PlatoonCsvImportStore.latestRetainedFile(directory) - if (latest == null) { - statusText.text = getString(R.string.status_no_platoon_csv) - return - } - - pendingExport = latest - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_TITLE, latest.name) - startActivityForResult(intent, REQUEST_EXPORT) - } - - private fun refreshHistory() { - val recentEntries = historyStore.list() - renderHistoryEntries( - container = historyContainer, - entries = recentEntries, - selectedIds = selectedHistoryIds, - saved = false, - emptyMessage = R.string.no_parsed_packets, - ) - historyActions.visibility = if (recentEntries.isEmpty()) View.GONE else View.VISIBLE - - val savedEntries = savedHistoryStore.list() - renderHistoryEntries( - container = savedHistoryContainer, - entries = savedEntries, - selectedIds = selectedSavedHistoryIds, - saved = true, - emptyMessage = R.string.no_saved_packets, - ) - savedHistoryActions.visibility = if (savedEntries.isEmpty()) View.GONE else View.VISIBLE - } - - private fun renderHistoryEntries( - container: LinearLayout, - entries: List, - selectedIds: MutableSet, - saved: Boolean, - emptyMessage: Int, - ) { - container.removeAllViews() - if (entries.isEmpty()) { - container.addView(TextView(this).apply { - text = getString(emptyMessage) - }, matchWidth()) - return - } - val rowHeight = dp(48) - val tagWidth = dp(112) - val tagHeight = dp(32) - entries.forEach { entry -> - container.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = rowHeight - addView(CheckBox(context).apply { - isChecked = entry.id in selectedIds - contentDescription = getString(R.string.select_history_entry, entry.title) - setOnCheckedChangeListener { _, checked -> - if (checked) selectedIds += entry.id else selectedIds -= entry.id - } - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT, - )) - addView(Button(context).apply { - text = entry.title - isAllCaps = false - useEvidenceActionStyle() - setOnClickListener { - startActivity( - Intent(this@MainActivity, PacketHistoryActivity::class.java) - .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) - .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) - .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, saved) - .putExtra( - PacketHistoryActivity.EXTRA_STORAGE_ID, - profileBinding.scope?.storageId, - ), - ) - } - }, LinearLayout.LayoutParams(0, rowHeight, 1f)) - addView(TextView(context).apply { - val localizedTag = localizedPayloadTag(entry.payloadType) - text = localizedTag - textSize = 12f - setTextColor(getColor(R.color.primary_action_foreground)) - gravity = Gravity.CENTER - setPadding(dp(8), 0, dp(8), 0) - background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = dp(10).toFloat() - setColor(getColor(R.color.primary_action_background)) - } - contentDescription = getString( - R.string.payload_tag_description, - localizedTag, - entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), - ) - }, LinearLayout.LayoutParams( - tagWidth, - tagHeight, - ).apply { marginStart = dp(8) }) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - } - } - - private fun localizedPayloadTag(payloadType: Int?): String = when (payloadType) { - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> getString(R.string.payload_tag_platoon) - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> getString(R.string.payload_tag_activity) - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> getString(R.string.payload_tag_updates) - Gfl2PayloadDecoder.TYPE_WEAPONS -> getString(R.string.payload_tag_weapons) - Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> getString(R.string.payload_tag_attachments) - Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> getString(R.string.payload_tag_common_keys) - Gfl2PayloadDecoder.TYPE_FORMATIONS -> getString(R.string.payload_tag_formations) - else -> PayloadCatalog.tag(payloadType) - } - - private fun deleteSelectedHistory() { - if (selectedHistoryIds.isEmpty()) { - statusText.text = getString(R.string.status_select_history_first) - return - } - val deleted = historyStore.delete(selectedHistoryIds) - selectedHistoryIds.clear() - statusText.text = resources.getQuantityString( - R.plurals.status_deleted_history, - deleted, - deleted, - ) - refreshHistory() - } - - private fun saveSelectedHistory() { - if (selectedHistoryIds.isEmpty()) { - statusText.text = getString(R.string.status_select_history_first) - return - } - val result = runCatching { - savedHistoryStore.saveFrom(historyStore, selectedHistoryIds) - }.getOrElse { - statusText.text = getString(R.string.status_save_history_failed) - return - } - selectedHistoryIds.clear() - statusText.text = when { - result.saved > 0 && result.limitReached -> resources.getQuantityString( - R.plurals.status_saved_history_at_limit, - result.saved, - result.saved, - SavedHistoryStore.MAX_ENTRIES, - ) - result.saved > 0 -> resources.getQuantityString( - R.plurals.status_saved_history, - result.saved, - result.saved, - ) - result.limitReached -> getString( - R.string.status_saved_history_limit, - SavedHistoryStore.MAX_ENTRIES, - ) - else -> getString(R.string.status_history_already_saved) - } - refreshHistory() - } - - private fun deleteSelectedSavedHistory() { - if (selectedSavedHistoryIds.isEmpty()) { - statusText.text = getString(R.string.status_select_saved_history_first) - return - } - val deleted = savedHistoryStore.delete(selectedSavedHistoryIds) - selectedSavedHistoryIds.clear() - statusText.text = resources.getQuantityString( - R.plurals.status_deleted_saved_history, - deleted, - deleted, - ) - refreshHistory() - } - - private fun matchWidth(): ViewGroup.LayoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() private companion object { - const val REQUEST_VPN = 100 - const val REQUEST_NOTIFICATIONS = 101 - const val REQUEST_EXPORT = 102 - const val REQUEST_BACKUP_EXPORT = 103 - const val REQUEST_BACKUP_IMPORT = 104 - const val REQUEST_CSV_IMPORT = 105 - const val MAX_CSV_IMPORT_FILES = 64 - const val MAX_CSV_IMPORT_BYTES = 16L * 1024 * 1024 - const val STATUS_REFRESH_MILLIS = 1_000L - const val EXTRA_LAUNCH_CSV_PICKER = "launch_csv_picker_for_storage_id" - const val STATE_CSV_TARGET = "pending_csv_target" - val BACKUP_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") - val BACKUP_MIME_TYPES = arrayOf( - PlatoonBackupManager.MIME_TYPE, - "application/zip", - "application/octet-stream", - ) + const val SUMMARY = "summary" + const val UNDO = "undo" + const val STATE_CAPTURE_ONCE = "home.capture_once" } - - private data class CsvImportSummary( - val retained: Int, - val duplicates: Int, - val imported: PlatoonRepository.ImportResult, - ) - - private data class PendingCsvImport( - val storageScope: PlatoonStorageScope, - val destinationProfile: PlatoonProfile, - val prepared: List, - val duplicateFileNames: Set, - val preview: CsvImportPreviewAnalyzer.Preview, - ) - - private data class CsvImportTarget( - val storageScope: PlatoonStorageScope, - val profile: PlatoonProfile, - ) } diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 593a39c..44b212e 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -1,385 +1,440 @@ package dev.gf2log.app +import dev.gf2log.app.management.ManualTextPolicy import android.app.AlertDialog -import android.graphics.Typeface import android.os.Bundle -import android.view.ViewGroup +import android.view.Gravity +import android.view.View import android.widget.Button import android.widget.EditText import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import dev.gf2log.app.management.EvidenceSource import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.MembershipPeriod import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.isImmutableMembershipBoundary +import dev.gf2log.app.management.SnapshotMember +import dev.gf2log.app.management.isJoinedBoundaryEditable +import dev.gf2log.app.management.isLeftBoundaryEditable import dev.gf2log.app.management.isValidMembershipRange -import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.onTextChanged +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField import java.time.Instant -import java.time.format.DateTimeFormatter +import java.time.LocalDate +import java.time.ZoneId -class MemberDetailActivity : LocalizedActivity() { +/** One member's profile, private note, and editable membership history. */ +class MemberDetailActivity : BaseActivity() { + + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository - private var uid: Long = -1 + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2MemberDetail") + private var uid = -1L + private var model: Model? = null + private var inputsInitialized = false + + private lateinit var content: LinearLayout + private lateinit var statusPanel: LinearLayout + private lateinit var periods: LinearLayout + private lateinit var nickname: EditText + private lateinit var note: EditText + private lateinit var saveButton: Button override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) uid = intent.getLongExtra(EXTRA_UID, -1) - repository = PlatoonRepository(this) - render() + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope + if (uid <= 0 || scope == null) { + finish() + return + } + repository = PlatoonRepository(this, scope) + inputsInitialized = savedInstanceState?.getBoolean(STATE_INPUTS_INITIALIZED) == true + screen = Screen(this) + onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { + override fun handleOnBackPressed() = leave() + }) + screen.topBar + .setNavigation(::leave) + .setTitle(getString(R.string.member_details)) + .setActions( + emptyList(), + overflow = listOf(MenuEntry(getString(R.string.delete_member)) { confirmMemberDeletion() }), + ) + buildContent() + screen.setBody(Screen.scrollingBody(this, content)) } - private fun render() { - val status = repository.listMemberStatuses().firstOrNull { it.uid == uid } ?: run { + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) { + // The member belongs to the previous Platoon scope. finish() return } - val nameInput = EditText(this).apply { - hint = getString(R.string.member_nickname) - setText(status.name) - setSingleLine(true) - } - val noteInput = EditText(this).apply { - hint = getString(R.string.member_private_note) - setText(status.note) - minLines = 2 + load() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_INPUTS_INITIALIZED, inputsInitialized) + super.onSaveInstanceState(outState) + } + + private fun buildContent() { + nickname = textField(getString(R.string.member_nickname), id = R.id.member_nickname_input, maxLength = ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + note = textField(getString(R.string.member_private_note_hint), id = R.id.member_note_input, singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS) + saveButton = button(getString(R.string.save_member), Controls.Role.PRIMARY) { saveProfile() } + nickname.onTextChanged { updateSaveState() } + note.onTextChanged { updateSaveState() } + statusPanel = panel() + periods = column() + content = column { + addView(statusPanel, lp(top = 4)) + addView(sectionHeader(getString(R.string.member_profile)), lp()) + addView(panel { + addView(LabeledField(context, getString(R.string.member_nickname), nickname, getString(R.string.member_nickname_detail)), lp()) + addView(LabeledField(context, getString(R.string.member_private_note), note, getString(R.string.member_private_note_detail)), lp(top = 12)) + addView(saveButton, lp(top = 12)) + }, lp()) + addView(row { + addView(sectionHeader(getString(R.string.membership_history)), lp(width = 0, weight = 1f)) + addView(button(getString(R.string.add_membership_history_short), Controls.Role.TERTIARY, R.drawable.ic_add) { + addMembershipPeriod() + }, lp(WRAP, WRAP, top = 8)) + }, lp()) + addView(periods, lp()) } - val content = ScrollView(this).apply { - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(TextView(context).apply { - text = getString(R.string.member_details) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(TextView(context).apply { - text = getString( - R.string.member_status_line, - status.uid, - getString( - if (status.isActive) { - R.string.active_member - } else { - R.string.departed_member - }, - ), + statusPanel.addView(StateViews.loading(this, getString(R.string.loading_members))) + updateSaveState() + } + + private fun load() { + work.run( + key = LOAD, + task = { + val status = repository.listMemberStatuses().firstOrNull { it.uid == uid } + status?.let { + Model( + status = it, + latest = repository.listSnapshots(1).firstOrNull()?.members?.firstOrNull { member -> member.uid == uid }, + zone = repository.gameZone(), ) - textSize = 14f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(4), 0, dp(8)) - }, matchWidth()) - addView(nameInput, matchWidth()) - addView(noteInput, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(context).apply { - text = getString(R.string.save_member) - usePrimaryActionStyle() - setOnClickListener { - val saved = runCatching { - repository.updateMember( - status.uid, - nameInput.text.toString(), - noteInput.text.toString(), - ) - }.getOrDefault(false) - TransientMessage.show( - this@MemberDetailActivity, - getString(if (saved) R.string.saved else R.string.save_failed), - Toast.LENGTH_SHORT, - ) - } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - marginEnd = dp(4) - }) - addView(Button(context).apply { - text = getString(R.string.add_membership_history_short) - useSecondaryActionStyle() - setOnClickListener { addMembershipHistory(status) } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - marginStart = dp(4) - }) - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.delete_member) - useDestructiveTextActionStyle() - setOnClickListener { confirmMemberDeletion(status) } - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.membership_history) - textSize = 17f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(16), 0, dp(4)) - }, matchWidth()) - status.membershipPeriods.forEachIndexed { index, membershipPeriod -> - addView(membershipPeriodRow(status, membershipPeriod, index), matchWidth()) } - }, matchWidth()) + }, + ) { result -> + val loaded = result.getOrNull() + if (loaded == null) { + if (result.isSuccess) finish() else render(null) + return@run + } + model = loaded + if (!inputsInitialized) { + nickname.setText(loaded.status.name) + note.setText(loaded.status.note) + inputsInitialized = true + } + render(loaded) } - setContentView(content) - ModernUi.prepareContent(window.decorView) } - private fun confirmMemberDeletion(status: MemberStatus) { - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.delete_member) - .setMessage(R.string.delete_member_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - setTextColor(getColor(R.color.destructive_action)) - setOnClickListener { - val deleted = runCatching { - repository.deleteMember(status.uid) - }.getOrDefault(false) - if (deleted) { - dialog.dismiss() - TransientMessage.show( - this@MemberDetailActivity, - R.string.member_deleted, - Toast.LENGTH_SHORT, - ) - finish() + private fun render(loaded: Model?) { + statusPanel.removeAllViews() + periods.removeAllViews() + if (loaded == null) { + statusPanel.addView(StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() }) + return + } + val status = loaded.status + screen.topBar.setTitle(status.name, Labels.uid(this, status.uid)) + statusPanel.addView(row { + addView(text(getString(R.string.member_since, firstSeen(status, loaded.zone)), TextRole.SECONDARY), lp(width = 0, weight = 1f)) + addView(badge( + getString(if (status.isActive) R.string.active_member else R.string.departed_member), + if (status.isActive) R.color.success_surface else R.color.surface_variant, + if (status.isActive) R.color.success_text else R.color.text_secondary, + ), lp(WRAP, WRAP)) + }, lp()) + val latest = loaded.latest + val stats = listOf( + getString(R.string.level) to (latest?.level ?: status.level).toString(), + getString(R.string.merit_this_week) to (latest?.weeklyMerit?.toString() ?: "—"), + getString(R.string.total_merit) to (latest?.totalMerit?.toString() ?: "—"), + getString(R.string.last_login) to (latest?.lastLogin?.takeIf { it > 0 }?.let { + Labels.format(Labels.DATE_TIME, Instant.ofEpochSecond(it), loaded.zone) + } ?: "—"), + ) + stats.chunked(2).forEach { pair -> + statusPanel.addView(row(Gravity.TOP) { + pair.forEachIndexed { index, (label, value) -> + addView(column { + isFocusable = true + contentDescription = Labels.labeled(context, label, value) + addView(text(label, TextRole.CAPTION) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp()) + addView(text(value, TextRole.SUBTITLE) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(top = 2)) + }, lp(width = 0, weight = 1f, start = if (index == 0) 0 else 12)) + } + }, lp(top = 12)) + } + val rows = status.membershipPeriods.mapIndexed { index, period -> + navigationRow( + title = getString(R.string.membership_period_title, index + 1) + + if (period.leftAt == null && period.leftDate == null) " · " + getString(R.string.current_period) else "", + detail = getString( + R.string.membership_period_detail, + boundaryText(period.joinedDate, period.joinedAt, period.joinedTimeKnown, loaded.zone), + if (period.leftAt == null && period.leftDate == null) { + getString(R.string.present) } else { - TransientMessage.show( - this@MemberDetailActivity, - R.string.member_delete_failed, - Toast.LENGTH_SHORT, - ) - } + boundaryText(period.leftDate, period.leftAt, period.leftTimeKnown ?: (period.leftAt != null), loaded.zone) + }, + sourceLabel(period.joinedSource), + period.leftSource?.let(::sourceLabel) ?: "—", + ) + period.note.takeIf(String::isNotBlank)?.let { "\n$it" }.orEmpty(), + icon = R.drawable.ic_calendar, + ) { editMembershipPeriod(status, period) } + } + periods.addView( + if (rows.isEmpty()) StateViews.empty(this, R.drawable.ic_calendar, getString(R.string.no_membership_periods)) else group(rows), + lp(), + ) + updateSaveState() + } + + /** Unsaved nickname or note edits are confirmed instead of silently discarded. */ + private fun leave() { + if (!::saveButton.isInitialized || !saveButton.isEnabled) { + finish() + return + } + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.unsaved_changes_title) + .setMessage(R.string.unsaved_changes_message) + .setNegativeButton(R.string.discard) { _, _ -> finish() } + // Leave only once the save lands; a failure keeps the screen and its edits. + .setPositiveButton(R.string.save_member) { _, _ -> saveProfile(thenFinish = true) }, + ) + } + + private fun updateSaveState() { + val loaded = model?.status + saveButton.isEnabled = loaded != null && !work.isBusy(SAVE) && + (nickname.text.toString() != loaded.name || note.text.toString() != loaded.note) && + nickname.text.isNotBlank() + } + + private fun saveProfile(thenFinish: Boolean = false) { + if (work.isBusy(SAVE)) return + val name = nickname.text.toString() + val noteText = note.text.toString() + work.run(key = SAVE, task = { repository.updateMember(uid, name, noteText) }) { result -> + val saved = result.getOrDefault(false) + Feedback.show(this, if (saved) R.string.saved else R.string.save_failed) + if (saved && thenFinish) finish() else load() + } + updateSaveState() + } + + private fun confirmMemberDeletion() { + Dialogs.confirm(this, R.string.delete_member, R.string.delete_member_warning, R.string.delete, destructive = true) confirm@{ + if (work.isBusy(DELETE)) return@confirm + work.run(key = DELETE, task = { repository.deleteMember(uid) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(this, R.string.member_deleted) + finish() + } else { + Feedback.show(this, R.string.member_delete_failed) } } } - dialog.show() } - private fun membershipPeriodRow( - status: MemberStatus, - membershipPeriod: MembershipPeriod, - index: Int, - ) = ModernUi.actionRow( - context = this, - title = getString(R.string.membership_period_title, index + 1), - detail = getString( - R.string.membership_period_dates, - format( - membershipPeriod.joinedDate, - membershipPeriod.joinedAt, - membershipPeriod.joinedTimeKnown, - ), - format( - membershipPeriod.leftDate, - membershipPeriod.leftAt, - membershipPeriod.leftTimeKnown ?: (membershipPeriod.leftAt != null), - ), - ), - onClick = { editMembershipPeriod(status, membershipPeriod) }, - ) - - private fun editMembershipPeriod(status: MemberStatus, membershipPeriod: MembershipPeriod) { + private fun editMembershipPeriod(status: MemberStatus, period: MembershipPeriod) { + val zone = model?.zone ?: return val joined = DateTimePickerInput( context = this, - label = getString(R.string.join_field), - initialValue = membershipPeriod.joinedAt, - initialDate = membershipPeriod.joinedDate, - initialTimeKnown = membershipPeriod.joinedTimeKnown, + label = getString(R.string.join_label), + initialValue = period.joinedAt, + initialDate = period.joinedDate, + initialTimeKnown = period.joinedTimeKnown, dateRequired = true, - editable = !membershipPeriod.joinedSource.isImmutableMembershipBoundary(), - zone = GameTimeZonePreferences.get(this), + editable = period.isJoinedBoundaryEditable, + zone = zone, ) val left = DateTimePickerInput( context = this, - label = getString(R.string.withdraw_field), - initialValue = membershipPeriod.leftAt, - initialDate = membershipPeriod.leftDate, - initialTimeKnown = membershipPeriod.leftTimeKnown ?: (membershipPeriod.leftAt != null), - editable = membershipPeriod.leftSource?.isImmutableMembershipBoundary() != true, - zone = GameTimeZonePreferences.get(this), + label = getString(R.string.withdraw_label), + initialValue = period.leftAt, + initialDate = period.leftDate, + initialTimeKnown = period.leftTimeKnown ?: (period.leftAt != null), + editable = period.isLeftBoundaryEditable, + zone = zone, ) - val note = EditText(this).apply { - hint = getString(R.string.membership_note_hint) - setText(membershipPeriod.note) + val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS).apply { setText(period.note) } + val error = errorLine() + val canDelete = status.membershipPeriods.size > 1 + val body = Dialogs.content(this) { + addView(joined, lp()) + addView(left, lp(top = 12)) + addView(LabeledField(context, getString(R.string.membership_note_hint), noteInput), lp(top = 12)) + addView(error, lp(top = 8)) + if (!canDelete) addView(text(getString(R.string.membership_period_delete_last_hint), TextRole.CAPTION), lp(top = 8)) } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), 0) - addView(joined, matchWidth()) - addView(left, matchWidth()) - addView(note, matchWidth()) - if (status.membershipPeriods.size == 1) { - addView(TextView(context).apply { - setText(R.string.membership_period_delete_last_hint) - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(8), 0, 0) - }, matchWidth()) - } - } - val dialog = AlertDialog.Builder(this) + val builder = Dialogs.builder(this) .setTitle(R.string.edit_membership) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.delete, null) + .setView(body) + .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.save_member, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).apply { + if (canDelete) builder.setNeutralButton(R.string.delete, null) + Dialogs.show(builder) { dialog -> + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { setTextColor(getColor(R.color.destructive_action)) - isEnabled = status.membershipPeriods.size > 1 - setOnClickListener { - if (isEnabled) confirmMembershipPeriodDeletion(dialog, membershipPeriod) - } + setOnClickListener { confirmMembershipPeriodDeletion(dialog, period) } } - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val joinedBoundary = joined.boundary val leftBoundary = left.boundary - val saved = joinedBoundary != null && - isValidMembershipRange(joinedBoundary, leftBoundary) && - runCatching { - repository.updateMembershipPeriod( - membershipPeriod.id, - joinedBoundary, - leftBoundary, - note.text.toString(), - ) - }.getOrDefault(false) - TransientMessage.show( - this, - getString(if (saved) R.string.saved else R.string.invalid_date), - Toast.LENGTH_SHORT, - ) - if (saved) { - dialog.dismiss() - render() + if (joinedBoundary == null || !isValidMembershipRange(joinedBoundary, leftBoundary)) { + error.showMessage(getString(R.string.invalid_date)) + return@setOnClickListener } - } - } - dialog.show() - } - - private fun confirmMembershipPeriodDeletion( - editor: AlertDialog, - membershipPeriod: MembershipPeriod, - ) { - val confirmation = AlertDialog.Builder(this) - .setTitle(R.string.delete_membership_period) - .setMessage(R.string.delete_membership_period_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - confirmation.setOnShowListener { - confirmation.getButton(AlertDialog.BUTTON_POSITIVE).apply { - setTextColor(getColor(R.color.destructive_action)) - setOnClickListener { - val deleted = runCatching { - repository.deleteMembershipPeriod(membershipPeriod.id) - }.getOrDefault(false) - if (deleted) { - confirmation.dismiss() - editor.dismiss() - TransientMessage.show( - this@MemberDetailActivity, - R.string.membership_period_deleted, - Toast.LENGTH_SHORT, - ) - render() + button.isEnabled = false + val noteText = noteInput.text.toString() + work.run( + task = { repository.updateMembershipPeriod(period.id, joinedBoundary, leftBoundary, noteText) }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + Feedback.show(this, R.string.saved) + load() } else { - TransientMessage.show( - this@MemberDetailActivity, - R.string.membership_period_delete_failed, - Toast.LENGTH_SHORT, - ) + error.showMessage(getString(R.string.membership_period_save_failed)) } } } } - confirmation.show() } - private fun addMembershipHistory(status: MemberStatus) { - val joined = DateTimePickerInput( + private fun confirmMembershipPeriodDeletion(editor: AlertDialog, period: MembershipPeriod) { + Dialogs.confirm( this, - getString(R.string.join_field), - dateRequired = true, - zone = GameTimeZonePreferences.get(this), - ) - val withdrew = DateTimePickerInput( - this, - getString(R.string.withdraw_field), - zone = GameTimeZonePreferences.get(this), - ) - val note = EditText(this).apply { - hint = getString(R.string.membership_note_hint) - minLines = 2 + R.string.delete_membership_period, + R.string.delete_membership_period_warning, + R.string.delete, + destructive = true, + ) confirm@{ + if (work.isBusy(DELETE)) return@confirm + work.run(key = DELETE, task = { repository.deleteMembershipPeriod(period.id) }) { result -> + if (result.getOrDefault(false)) { + editor.dismiss() + Feedback.show(this, R.string.membership_period_deleted) + load() + } else { + Feedback.show(this, R.string.membership_period_delete_failed, long = true) + } + } } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), 0) - addView(joined, matchWidth()) - addView(withdrew, matchWidth()) - addView(note, matchWidth()) + } + + private fun addMembershipPeriod() { + val zone = model?.zone ?: return + val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone) + val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), zone = zone) + val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS) + val error = errorLine() + val body = Dialogs.content(this) { + addView(joined, lp()) + addView(withdrew, lp(top = 12)) + addView(LabeledField(context, getString(R.string.membership_note_hint), noteInput), lp(top = 12)) + addView(error, lp(top = 8)) } - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.add_membership_history) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.add, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.add_membership_history) + .setView(body) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { dialog -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val joinedBoundary = joined.boundary val withdrewBoundary = withdrew.boundary - val saved = joinedBoundary != null && - isValidMembershipRange(joinedBoundary, withdrewBoundary) && - runCatching { - repository.addMembershipPeriod( - status.uid, - joinedBoundary, - withdrewBoundary, - note.text.toString(), - ) - }.getOrDefault(false) - if (saved) { - dialog.dismiss() - render() - } else { - TransientMessage.show(this, R.string.invalid_date) + if (joinedBoundary == null || !isValidMembershipRange(joinedBoundary, withdrewBoundary)) { + error.showMessage(getString(R.string.invalid_date)) + return@setOnClickListener + } + button.isEnabled = false + val noteText = noteInput.text.toString() + work.run( + task = { repository.addMembershipPeriod(uid, joinedBoundary, withdrewBoundary, noteText) }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + load() + } else { + error.showMessage(getString(R.string.membership_period_overlap)) + } } } } - dialog.show() } - private fun format( - date: java.time.LocalDate?, - instant: Instant?, - timeKnown: Boolean, - ): String { - val zone = GameTimeZonePreferences.get(this) - val displayDate = date ?: instant?.atZone(zone)?.toLocalDate() - ?: return getString(R.string.unknown) + private fun boundaryText(date: LocalDate?, instant: Instant?, timeKnown: Boolean, zone: ZoneId): String { + val displayDate = date ?: instant?.atZone(zone)?.toLocalDate() ?: return getString(R.string.unknown) return if (timeKnown && instant != null) { - instant.atZone(zone).format(DISPLAY_TIME) + Labels.format(Labels.DATE_TIME, instant, zone) } else { - displayDate.format(DISPLAY_DATE) + Labels.format(Labels.DATE, displayDate) } } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, + private fun firstSeen(status: MemberStatus, zone: ZoneId): String = + Labels.format(Labels.DATE, status.firstSeenAt.atZone(zone).toLocalDate()) + + private fun sourceLabel(source: EvidenceSource): String = getString( + when (source) { + EvidenceSource.GAME_UPDATES -> R.string.source_updates + EvidenceSource.SNAPSHOT -> R.string.source_roster + EvidenceSource.MANUAL -> R.string.source_manual + EvidenceSource.LEGACY_IMPORT -> R.string.source_legacy + }, + ) + + private data class Model( + val status: MemberStatus, + val latest: SnapshotMember?, + val zone: ZoneId, ) companion object { const val EXTRA_UID = "uid" - private val DISPLAY_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm") - private val DISPLAY_DATE = DateTimeFormatter.ofPattern("yy/MM/dd") + private const val LOAD = "load" + private const val SAVE = "save" + private const val DELETE = "delete" + private const val STATE_INPUTS_INITIALIZED = "member.inputs_initialized" } } diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index b6305e3..0ff93b5 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -1,22 +1,22 @@ package dev.gf2log.app -import android.animation.LayoutTransition -import android.content.ClipData -import android.graphics.drawable.GradientDrawable -import android.graphics.Typeface +import android.annotation.SuppressLint import android.os.Bundle -import android.view.DragEvent +import android.text.TextUtils import android.view.Gravity import android.view.HapticFeedbackConstants +import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Button import android.widget.ImageView import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Spinner import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.MemberOrderSorter import dev.gf2log.app.management.MemberSortDirection import dev.gf2log.app.management.MemberSortField @@ -24,266 +24,327 @@ import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.settings.MemberOrderPreferences +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text -class MemberOrderActivity : LocalizedActivity() { - private lateinit var listContainer: LinearLayout - private lateinit var scrollView: ScrollView +/** + * Persists the member order used by weekly tables. Members can be sorted once, then fine-tuned by + * dragging the handle or through the accessibility "move up/down" actions; every change saves. + */ +class MemberOrderActivity : BaseActivity() { + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var repository: PlatoonRepository private lateinit var preferences: MemberOrderPreferences - private lateinit var latestMembers: Map + private val work = BackgroundWork(this, "GF2MemberOrder") + private val adapter = OrderAdapter() + private val stateAdapter = SingleViewAdapter() + private lateinit var touchHelper: ItemTouchHelper private var members = mutableListOf() - private var defaultMembers = emptyList() - private var draggedUid: Long? = null - private var dragCommitted = false + private var latestMembers = emptyMap() + private var sortField = MemberSortField.JOIN_DATE + private var sortDirection = MemberSortDirection.ASCENDING + private lateinit var fieldButton: TextView + private lateinit var directionButton: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - preferences = MemberOrderPreferences(this) - val repository = PlatoonRepository(this) - defaultMembers = MemberOrderSorter.sort( - members = repository.listMemberStatuses(activeOnly = true), - latestMembers = emptyMap(), - field = MemberSortField.JOIN_DATE, - direction = MemberSortDirection.ASCENDING, - ) - latestMembers = repository.listSnapshots(limit = 1) - .firstOrNull() - ?.members - .orEmpty() - .associateBy(SnapshotMember::uid) - members = initialOrder(defaultMembers).toMutableList() - setContentView(buildContentView()) - } - - private fun initialOrder(active: List): List = - if (preferences.read().isNotEmpty()) { - preferences.apply(active, MemberStatus::uid) - } else { - active + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope ?: run { + finish() + return } - - private fun buildContentView(): ScrollView { - val sortField = Spinner(this).apply { - adapter = ArrayAdapter( - this@MemberOrderActivity, - android.R.layout.simple_spinner_dropdown_item, - listOf( - getString(R.string.sort_join_date), - getString(R.string.sort_weekly_merit), - getString(R.string.sort_total_merit), - ), - ) + repository = PlatoonRepository(this, scope) + preferences = MemberOrderPreferences(this, scope.storageId) + savedInstanceState?.let { state -> + sortField = MemberSortField.entries.getOrElse(state.getInt(STATE_SORT_FIELD)) { sortField } + sortDirection = MemberSortDirection.entries.getOrElse(state.getInt(STATE_SORT_DIRECTION)) { sortDirection } } - val sortDirection = Spinner(this).apply { - adapter = ArrayAdapter( - this@MemberOrderActivity, - android.R.layout.simple_spinner_dropdown_item, - listOf( - getString(R.string.ascending), - getString(R.string.descending), - ), + val screen = Screen(this) + screen.topBar + .setNavigation(::finish) + .setTitle(getString(R.string.member_order)) + .setActions( + emptyList(), + overflow = listOf(MenuEntry(getString(R.string.reset_member_order)) { confirmReset() }), ) + val list = RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(SingleViewAdapter(buildHeader()), this@MemberOrderActivity.adapter, stateAdapter) + setPadding(dp(16), 0, dp(16), dp(24)) } - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(TextView(context).apply { - text = getString(R.string.member_order) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.member_order_description) - setPadding(0, dp(6), 0, dp(10)) - }, matchWidth()) - addView(sortControlRow(R.string.sort_criterion, sortField), matchWidth()) - addView(sortControlRow(R.string.sort_direction, sortDirection), matchWidth()) - addView(Button(context).apply { - text = getString(R.string.apply_sort) - usePrimaryActionStyle() - setOnClickListener { - members = MemberOrderSorter.sort( - members = members, - latestMembers = latestMembers, - field = MemberSortField.entries[sortField.selectedItemPosition], - direction = MemberSortDirection.entries[sortDirection.selectedItemPosition], - ).toMutableList() - saveAndRender() - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.reset_member_order) - useTertiaryActionStyle() - setOnClickListener { - preferences.clear() - members = defaultMembers.toMutableList() - render() - } - }, matchWidth()) - listContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - layoutTransition = LayoutTransition().apply { - setDuration(DRAG_TRANSITION_MILLIS) - } - setOnDragListener(::handleListDrag) - } - addView(listContainer, matchWidth()) - } - scrollView = ScrollView(this).apply { addView(body, matchWidth()) } - render() - return scrollView + touchHelper = ItemTouchHelper(DragCallback()).apply { attachToRecyclerView(list) } + screen.setBody(list) + stateAdapter.view = StateViews.loading(this, getString(R.string.loading_members)) + load() + } + + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_SORT_FIELD, sortField.ordinal) + outState.putInt(STATE_SORT_DIRECTION, sortDirection.ordinal) + super.onSaveInstanceState(outState) } - private fun sortControlRow(label: Int, spinner: Spinner) = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL + private fun buildHeader(): View = column { + addView(text(getString(R.string.member_order_description), TextRole.SECONDARY), lp(top = 4)) + fieldButton = choiceChip { chooseField() } + directionButton = choiceChip { toggleDirection() } + addView(panel(padding = 12) { + addView(text(getString(R.string.sort_once), TextRole.LABEL), lp()) + addView(row { + addView(fieldButton, lp(width = 0, weight = 1.4f)) + addView(directionButton, lp(width = 0, weight = 1f, start = 8)) + }, lp(top = 8)) + addView(button(getString(R.string.apply_sort), Controls.Role.SECONDARY) { confirmSort() }, lp(top = 8)) + }, lp(top = 12, bottom = 8)) + updateSortLabels() + } + + private fun choiceChip(onClick: () -> Unit): TextView = text(null, TextRole.BODY) { gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { - text = getString(label) - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(spinner, LinearLayout.LayoutParams(0, wrap(), 2f)) + minHeight = dp(48) + setPadding(dp(12), dp(6), dp(8), dp(6)) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + background = android.graphics.drawable.GradientDrawable().apply { + setColor(getColor(R.color.surface_variant)) + cornerRadius = dpf(12f) + } + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + compoundDrawableTintList = android.content.res.ColorStateList.valueOf(getColor(R.color.text_secondary)) + isClickable = true + isFocusable = true + setOnClickListener { onClick() } } - private fun render() { - if (!::listContainer.isInitialized) return - listContainer.removeAllViews() - members.forEachIndexed { index, member -> - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(2), 0, dp(2)) - tag = member.uid - addView(TextView(context).apply { - text = "${index + 1}. ${member.name}\n#${member.uid}" - setPadding(dp(8), dp(6), dp(8), dp(6)) - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - } - row.addView(ImageView(this).apply { - setImageResource(R.drawable.ic_drag_handle) - setPadding(dp(14), dp(14), dp(14), dp(14)) - contentDescription = getString(R.string.drag_member, member.name) - isFocusable = true - setOnLongClickListener { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - startDragAndDrop( - ClipData.newPlainText("member_uid", member.uid.toString()), - View.DragShadowBuilder(row), - member.uid, - 0, - ) - true - } - }, LinearLayout.LayoutParams(dp(52), dp(52))) - listContainer.addView(row, matchWidth()) + private fun load() { + work.run( + task = { + val active = repository.listMemberStatuses(activeOnly = true) + val defaults = MemberOrderSorter.sort(active, emptyMap(), MemberSortField.JOIN_DATE, MemberSortDirection.ASCENDING) + val ordered = if (preferences.read().isEmpty()) defaults else preferences.apply(defaults, MemberStatus::uid) + ordered to repository.listSnapshots(1).firstOrNull()?.members.orEmpty().associateBy(SnapshotMember::uid) + }, + ) { result -> + result.fold( + onSuccess = { (ordered, latest) -> + members = ordered.toMutableList() + latestMembers = latest + stateAdapter.view = if (members.isEmpty()) { + StateViews.empty(this, R.drawable.ic_group, getString(R.string.no_active_members)) + } else { + null + } + adapter.notifyDataSetChanged() + }, + onFailure = { stateAdapter.view = StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() } }, + ) } - draggedUid?.let(::highlightDraggedRow) } - private fun handleListDrag(view: View, event: DragEvent): Boolean { - if (event.localState !is Long) return false - when (event.action) { - DragEvent.ACTION_DRAG_STARTED -> { - draggedUid = event.localState as Long - dragCommitted = false - highlightDraggedRow(draggedUid ?: return false) - } - DragEvent.ACTION_DRAG_LOCATION -> { - autoScroll(event.y) - previewDraggedMember(event.localState as Long, event.y) - } - DragEvent.ACTION_DROP -> { - dragCommitted = true - preferences.write(members.map(MemberStatus::uid)) - draggedUid = null - render() - } - DragEvent.ACTION_DRAG_ENDED -> { - if (!dragCommitted) { - preferences.write(members.map(MemberStatus::uid)) - } - draggedUid = null - render() - } + private fun chooseField() { + val fields = MemberSortField.entries + Dialogs.choose(this, getString(R.string.sort_criterion), fields.map { getString(fieldLabel(it)) }, sortField.ordinal) { + sortField = fields[it] + updateSortLabels() } - return true } - private fun autoScroll(y: Float) { - val location = IntArray(2) - listContainer.getLocationOnScreen(location) - val screenY = location[1] + y - val scrollLocation = IntArray(2) - scrollView.getLocationOnScreen(scrollLocation) - val top = scrollLocation[1] - val bottom = top + scrollView.height - when { - screenY < top + dp(72) -> scrollView.scrollBy(0, -dp(24)) - screenY > bottom - dp(72) -> scrollView.scrollBy(0, dp(24)) + private fun toggleDirection() { + sortDirection = if (sortDirection == MemberSortDirection.ASCENDING) { + MemberSortDirection.DESCENDING + } else { + MemberSortDirection.ASCENDING } + updateSortLabels() + } + + private fun updateSortLabels() { + fieldButton.text = getString(fieldLabel(sortField)) + fieldButton.contentDescription = Labels.labeled(this, getString(R.string.sort_criterion), fieldButton.text) + directionButton.text = getString( + if (sortDirection == MemberSortDirection.ASCENDING) R.string.ascending else R.string.descending, + ) + directionButton.contentDescription = Labels.labeled(this, getString(R.string.sort_direction), directionButton.text) } - private fun previewDraggedMember(uid: Long, y: Float) { - val from = members.indexOfFirst { it.uid == uid } - if (from < 0) return - var target = members.size - for (index in 0 until listContainer.childCount) { - val child = listContainer.getChildAt(index) - if (y < child.top + child.height / 2f) { - target = index - break + private fun fieldLabel(field: MemberSortField) = when (field) { + MemberSortField.JOIN_DATE -> R.string.sort_join_date + MemberSortField.WEEKLY_MERIT -> R.string.sort_weekly_merit + MemberSortField.TOTAL_MERIT -> R.string.sort_total_merit + } + + /** Sorting replaces any hand-arranged order, so it is confirmed first. */ + private fun confirmSort() { + Dialogs.confirm(this, R.string.apply_sort, R.string.apply_sort_warning, R.string.apply_sort) { applySort() } + } + + private fun applySort() { + members = MemberOrderSorter.sort(members, latestMembers, sortField, sortDirection).toMutableList() + adapter.notifyDataSetChanged() + persist() + } + + private fun confirmReset() { + Dialogs.confirm(this, R.string.reset_member_order, R.string.reset_member_order_warning, R.string.reset) { + work.run(task = { preferences.clear() }) { + Feedback.show(this, R.string.member_order_reset) + load() } } - if (target > from) target -= 1 - target = target.coerceIn(0, members.lastIndex) - if (target == from) return + } - val member = members.removeAt(from) - members.add(target, member) - val row = listContainer.getChildAt(from) - listContainer.removeViewAt(from) - listContainer.addView(row, target) - refreshVisiblePositions() - listContainer.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) - highlightDraggedRow(uid) + private fun move(from: Int, to: Int) { + if (from !in members.indices || to !in members.indices || from == to) return + members.add(to, members.removeAt(from)) + adapter.notifyItemMoved(from, to) + adapter.notifyItemRangeChanged(minOf(from, to), kotlin.math.abs(from - to) + 1, POSITION_PAYLOAD) } - private fun refreshVisiblePositions() { - members.forEachIndexed { index, member -> - val row = listContainer.getChildAt(index) as? LinearLayout ?: return@forEachIndexed - val label = row.getChildAt(0) as? TextView ?: return@forEachIndexed - label.text = "${index + 1}. ${member.name}\n#${member.uid}" + private fun persist() { + val order = members.map(MemberStatus::uid) + work.run(task = { check(preferences.write(order)) }) { result -> + if (result.isFailure) Feedback.show(this, R.string.save_failed) } } - private fun highlightDraggedRow(uid: Long) { - for (index in 0 until listContainer.childCount) { - val row = listContainer.getChildAt(index) - val selected = row.tag == uid - row.elevation = if (selected) dp(5).toFloat() else 0f - row.background = if (selected) { - GradientDrawable().apply { - setColor(getColor(R.color.accent_surface)) - setStroke(dp(2), getColor(R.color.accent)) - cornerRadius = dp(2).toFloat() + private inner class OrderAdapter : RecyclerView.Adapter() { + override fun getItemCount() = members.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = OrderHolder() + override fun onBindViewHolder(holder: OrderHolder, position: Int) = holder.bind(members[position], position) + } + + private inner class OrderHolder : RecyclerView.ViewHolder(LinearLayout(this@MemberOrderActivity)) { + private val position = text(null, TextRole.LABEL) { + gravity = Gravity.CENTER + minWidth = dp(32) + } + private val name = text(null, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val detail = text(null, TextRole.CAPTION) + private val handle = icon(R.drawable.ic_drag_handle).apply { + scaleType = ImageView.ScaleType.CENTER + } + + @SuppressLint("ClickableViewAccessibility") + private fun install() { + handle.setOnTouchListener { view, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + touchHelper.startDrag(this) } - } else { - null + false } } - } - private fun saveAndRender() { - preferences.write(members.map(MemberStatus::uid)) - render() + init { + (itemView as LinearLayout).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(60) + setPadding(dp(4), dp(6), 0, dp(6)) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dpf(12f) + } + layoutParams = RecyclerView.LayoutParams(MATCH, WRAP).apply { bottomMargin = dp(4) } + addView(position, lp(WRAP, WRAP, end = 8)) + addView(column { + addView(name, lp()) + addView(detail, lp()) + }, lp(width = 0, weight = 1f)) + addView(handle, lp(48, 48)) + } + install() + } + + fun bind(member: MemberStatus, index: Int) { + position.text = (index + 1).toString() + name.text = member.name + detail.text = Labels.uid(this@MemberOrderActivity, member.uid) + itemView.contentDescription = getString(R.string.member_order_position, index + 1, members.size, member.name) + handle.contentDescription = getString(R.string.drag_member, member.name) + ViewCompat.removeAccessibilityAction(itemView, R.id.action_move_up) + ViewCompat.removeAccessibilityAction(itemView, R.id.action_move_down) + if (index > 0) { + ViewCompat.replaceAccessibilityAction( + itemView, + AccessibilityNodeInfoCompat.AccessibilityActionCompat(R.id.action_move_up, getString(R.string.move_up)), + getString(R.string.move_up), + ) { _, _ -> move(bindingAdapterPosition, bindingAdapterPosition - 1).also { persist() }; true } + } + if (index < members.lastIndex) { + ViewCompat.replaceAccessibilityAction( + itemView, + AccessibilityNodeInfoCompat.AccessibilityActionCompat(R.id.action_move_down, getString(R.string.move_down)), + getString(R.string.move_down), + ) { _, _ -> move(bindingAdapterPosition, bindingAdapterPosition + 1).also { persist() }; true } + } + } } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private inner class DragCallback : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { + override fun isLongPressDragEnabled() = true + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = + if (viewHolder is OrderHolder) super.getMovementFlags(recyclerView, viewHolder) else 0 + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean { + if (target !is OrderHolder) return false + move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + recyclerView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder?.itemView?.background?.setTint(getColor(R.color.accent_surface)) + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.background?.setTint(getColor(R.color.surface)) + persist() + } + } private companion object { - const val DRAG_TRANSITION_MILLIS = 120L + val POSITION_PAYLOAD = Any() + const val STATE_SORT_FIELD = "member_order.sort_field" + const val STATE_SORT_DIRECTION = "member_order.sort_direction" } } diff --git a/app/src/main/java/dev/gf2log/app/ModernFeedback.kt b/app/src/main/java/dev/gf2log/app/ModernFeedback.kt deleted file mode 100644 index 774b10a..0000000 --- a/app/src/main/java/dev/gf2log/app/ModernFeedback.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.gf2log.app - -import android.app.AlertDialog -import android.content.Context -import android.widget.Toast -import androidx.annotation.StringRes - -/** Keeps app-owned dialogs visually consistent while preserving Android dialog behavior. */ -internal fun AlertDialog.applyModernDialogStyle(): AlertDialog = apply { - window?.setBackgroundDrawable(ModernUi.dialogBackground(context)) - getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(context.getColor(R.color.accent)) - getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(context.getColor(R.color.text_secondary)) - getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(context.getColor(R.color.text_secondary)) -} - -internal fun AlertDialog.Builder.showModern(): AlertDialog = show().applyModernDialogStyle() - -/** - * Replaces the currently visible transient message instead of extending a long Toast queue. - * Android does not expose its internal queue, so cancelling the prior app-owned Toast is the - * deterministic way to keep rapid repeated actions responsive. - */ -internal object TransientMessage { - private var active: Toast? = null - - @Synchronized - fun show(context: Context, message: CharSequence, duration: Int = Toast.LENGTH_SHORT) { - active?.cancel() - active = Toast.makeText(context.applicationContext, message, duration).also(Toast::show) - } - - fun show(context: Context, @StringRes message: Int, duration: Int = Toast.LENGTH_SHORT) { - show(context, context.getString(message), duration) - } -} diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt deleted file mode 100644 index ccd95e9..0000000 --- a/app/src/main/java/dev/gf2log/app/ModernUi.kt +++ /dev/null @@ -1,603 +0,0 @@ -package dev.gf2log.app - -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.InsetDrawable -import android.graphics.drawable.RippleDrawable -import android.graphics.drawable.StateListDrawable -import android.text.TextUtils -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.TextView -import androidx.annotation.DrawableRes - -/** Shared presentation primitives for the warm, editorial product surface. */ -object ModernUi { - internal enum class ControlRole { - PRIMARY, - CAPTURE, - FEATURE, - EVIDENCE, - SECONDARY, - TERTIARY, - NAVIGATION, - SELECTOR, - DESTRUCTIVE, - DESTRUCTIVE_TEXT, - } - - fun prepareContent(root: View) { - root.setBackgroundColor(root.context.getColor(R.color.app_background)) - prepareScreenSurface(root) - applyControls(root) - } - - fun panelBackground(context: Context, emphasized: Boolean = false): GradientDrawable = - GradientDrawable().apply { - setColor(context.getColor(if (emphasized) R.color.accent_surface else R.color.surface)) - cornerRadius = context.dp(16).toFloat() - } - - /** Theme-aware surface used by app-owned dialogs without replacing native semantics. */ - fun dialogBackground(context: Context): GradientDrawable = GradientDrawable().apply { - setColor(context.getColor(R.color.surface)) - cornerRadius = context.dp(20).toFloat() - setStroke(context.dp(1), context.getColor(R.color.outline)) - } - - /** Flat list navigation used by dashboard utilities and grouped Settings rows. */ - fun listRow( - context: Context, - title: CharSequence, - detail: CharSequence? = null, - @DrawableRes icon: Int? = null, - onClick: () -> Unit, - ): View = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(AccessibleActionRow(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = context.dp(if (detail.isNullOrBlank()) 52 else 60) - setPadding(context.dp(8), context.dp(4), context.dp(4), context.dp(4)) - background = statefulSurface( - context = context, - normalColor = R.color.app_background, - pressedColor = R.color.surface_pressed, - disabledColor = R.color.app_background, - normalStroke = android.R.color.transparent, - pressedStroke = android.R.color.transparent, - radiusDp = 2, - ) - contentDescription = listOfNotNull(title, detail?.takeIf(CharSequence::isNotBlank)) - .joinToString(". ") - setOnClickListener { onClick() } - icon?.let { drawable -> - addView(ImageView(context).apply { - setImageResource(drawable) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply { - marginEnd = context.dp(12) - }) - } - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - addView(TextView(context).apply { - text = title - textSize = 14f - setTextColor(context.getColor(R.color.text_primary)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - detail?.takeIf(CharSequence::isNotBlank)?.let { supportingText -> - addView(TextView(context).apply { - text = supportingText - textSize = 12f - setTextColor(context.getColor(R.color.text_secondary)) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageView(context).apply { - setImageResource(R.drawable.ic_chevron_right) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(context.dp(22), context.dp(22)).apply { - marginStart = context.dp(8) - }) - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - addView(View(context).apply { - setBackgroundColor(context.getColor(R.color.outline)) - }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, context.dp(1)).apply { - marginStart = context.dp(if (icon == null) 8 else 44) - }) - } - - /** - * Creates a compact multi-line navigation row without presenting its copy as an oversized button. - * The complete row remains a native 56dp touch target and exposes button semantics. - */ - fun actionRow( - context: Context, - title: CharSequence, - detail: CharSequence? = null, - titleMaxLines: Int = 1, - onClick: () -> Unit, - ): View = AccessibleActionRow(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = context.dp(56) - setPadding(context.dp(12), context.dp(8), context.dp(8), context.dp(8)) - background = statefulSurface( - context = context, - normalColor = R.color.surface, - pressedColor = R.color.surface_pressed, - disabledColor = R.color.surface_variant, - normalStroke = android.R.color.transparent, - pressedStroke = R.color.outline, - radiusDp = 14, - ) - contentDescription = listOfNotNull(title, detail?.takeIf(CharSequence::isNotBlank)) - .joinToString(". ") - setOnClickListener { onClick() } - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - addView(TextView(context).apply { - text = title - textSize = 15f - setTextColor(context.getColor(R.color.text_primary)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - maxLines = titleMaxLines - ellipsize = TextUtils.TruncateAt.END - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - detail?.takeIf(CharSequence::isNotBlank)?.let { supportingText -> - addView(TextView(context).apply { - text = supportingText - textSize = 13f - setTextColor(context.getColor(R.color.text_secondary)) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - setPadding(0, context.dp(4), 0, 0) - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageView(context).apply { - setImageResource(R.drawable.ic_chevron_right) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(context.dp(28), context.dp(28)).apply { - marginStart = context.dp(8) - }) - } - - /** Compact mutually-exclusive choices used for language and theme settings. */ - fun segmentedControl( - context: Context, - options: List>, - selectedValue: String, - onSelected: (String) -> Unit, - ): View { - require(options.isNotEmpty()) { "A segmented control requires at least one option" } - val selectedTextColors = ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.accent_text), - context.getColor(R.color.text_primary), - ), - ) - val railInset = context.dp(4) - val segmentHeight = context.dp(40) - val selection = View(context).apply { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - background = GradientDrawable().apply { - setColor(context.getColor(R.color.accent_surface)) - cornerRadius = context.dp(10).toFloat() - } - } - val choices = RadioGroup(context).apply { - orientation = RadioGroup.HORIZONTAL - background = null - } - val rail = FrameLayout(context).apply { - minimumHeight = context.dp(48) - setPadding(railInset, railInset, railInset, railInset) - background = GradientDrawable().apply { - setColor(context.getColor(R.color.surface_variant)) - cornerRadius = context.dp(14).toFloat() - } - addView(selection, FrameLayout.LayoutParams(0, segmentHeight)) - addView( - choices, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - segmentHeight, - ), - ) - } - - var currentValue = selectedValue - fun moveSelection(index: Int, animated: Boolean, finished: (() -> Unit)? = null) { - val segment = choices.getChildAt(index) - if (segment == null || segment.width <= 0) { - rail.post { moveSelection(index, animated, finished) } - return - } - selection.layoutParams = (selection.layoutParams as FrameLayout.LayoutParams).apply { - width = segment.width - height = segmentHeight - } - val target = segment.left.toFloat() - selection.animate().cancel() - if (animated) { - choices.isEnabled = false - repeat(choices.childCount) { child -> choices.getChildAt(child).isEnabled = false } - selection.animate() - .translationX(target) - .setDuration(180L) - .withEndAction { - choices.isEnabled = true - repeat(choices.childCount) { child -> choices.getChildAt(child).isEnabled = true } - finished?.invoke() - } - .start() - } else { - selection.translationX = target - finished?.invoke() - } - } - - options.forEachIndexed { index, (value, label) -> - choices.addView(RadioButton(context).apply { - id = View.generateViewId() - text = label - textSize = 14f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - gravity = Gravity.CENTER - buttonDrawable = null - background = RippleDrawable( - ColorStateList.valueOf(context.getColor(R.color.accent_surface_pressed)), - null, - shape( - context, - android.R.color.white, - android.R.color.transparent, - 10, - ), - ) - setTextColor(selectedTextColors) - isChecked = value == selectedValue - minHeight = segmentHeight - minWidth = context.dp(48) - setPadding(0, 0, 0, 0) - setOnClickListener { - if (value == currentValue) return@setOnClickListener - currentValue = value - moveSelection(index, animated = true) { onSelected(value) } - } - }, RadioGroup.LayoutParams(0, segmentHeight, 1f)) - } - rail.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> - if (right - left == oldRight - oldLeft) return@addOnLayoutChangeListener - val selectedIndex = options.indexOfFirst { it.first == currentValue }.coerceAtLeast(0) - moveSelection(selectedIndex, animated = false) - } - return rail - } - - internal fun styleButton(button: Button) { - val role = button.getTag(R.id.gf2_ui_role) as? ControlRole ?: ControlRole.SECONDARY - button.isAllCaps = false - button.elevation = 0f - button.stateListAnimator = null - button.minHeight = button.context.dp(48) - button.minWidth = button.context.dp(48) - val compactMultiline = button.getTag(R.id.gf2_ui_compact_multiline) == true - button.textSize = when { - compactMultiline -> 12f - role == ControlRole.FEATURE -> 13f - role == ControlRole.EVIDENCE -> 12f - else -> 14f - } - button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - button.maxLines = if (role == ControlRole.FEATURE || compactMultiline) 2 else 1 - button.ellipsize = TextUtils.TruncateAt.END - button.gravity = if (role == ControlRole.NAVIGATION || role == ControlRole.FEATURE) { - Gravity.START or Gravity.CENTER_VERTICAL - } else { - Gravity.CENTER - } - button.setPaddingRelative(button.context.dp(12), 0, button.context.dp(12), 0) - - val colors = when (role) { - ControlRole.PRIMARY -> ButtonColors( - normal = R.color.primary_action_background, - pressed = R.color.primary_action_pressed, - disabled = R.color.surface_variant, - stroke = R.color.primary_action_background, - pressedStroke = R.color.primary_action_pressed, - text = R.color.primary_action_foreground, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.CAPTURE -> ButtonColors( - normal = R.color.success_surface, - pressed = R.color.success_pressed, - disabled = R.color.surface_variant, - stroke = R.color.success_text, - pressedStroke = R.color.success_text, - text = R.color.success_text, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.FEATURE -> ButtonColors( - normal = R.color.accent_surface, - pressed = R.color.accent_surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.accent, - text = R.color.accent_text, - insetVertical = 4, - radiusDp = 14, - ) - ControlRole.EVIDENCE -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.outline_strong, - text = R.color.text_primary, - insetVertical = 8, - radiusDp = 10, - ) - ControlRole.DESTRUCTIVE -> ButtonColors( - normal = R.color.destructive_action_background, - pressed = R.color.destructive_action_pressed, - disabled = R.color.surface_variant, - stroke = R.color.destructive_action_background, - pressedStroke = R.color.destructive_action_pressed, - text = R.color.destructive_action_foreground, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.TERTIARY -> ButtonColors( - normal = R.color.app_background, - pressed = R.color.surface_pressed, - disabled = R.color.app_background, - stroke = android.R.color.transparent, - pressedStroke = android.R.color.transparent, - text = R.color.text_primary, - insetVertical = 5, - ) - ControlRole.DESTRUCTIVE_TEXT -> ButtonColors( - normal = R.color.app_background, - pressed = R.color.surface_pressed, - disabled = R.color.app_background, - stroke = android.R.color.transparent, - pressedStroke = android.R.color.transparent, - text = R.color.destructive_action, - insetVertical = 5, - ) - ControlRole.NAVIGATION -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.outline_strong, - text = R.color.text_primary, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.SELECTOR -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = R.color.outline_strong, - pressedStroke = R.color.accent, - text = R.color.text_primary, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.SECONDARY -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.outline_strong, - text = R.color.text_primary, - insetVertical = 4, - radiusDp = 12, - ) - } - button.setTextColor(buttonTextColors(button.context, colors.text)) - button.backgroundTintList = null - button.background = InsetDrawable( - statefulSurface( - context = button.context, - normalColor = colors.normal, - pressedColor = colors.pressed, - disabledColor = colors.disabled, - normalStroke = colors.stroke, - pressedStroke = colors.pressedStroke, - radiusDp = colors.radiusDp, - ), - 0, - button.context.dp(colors.insetVertical), - 0, - button.context.dp(colors.insetVertical), - ) - // A restored Button can keep the drawable state from its previous platform - // background even though View.isEnabled is already true. Synchronize the new - // semantic drawable immediately after replacing it. - button.refreshDrawableState() - button.jumpDrawablesToCurrentState() - } - - private fun applyControls(view: View) { - when (view) { - is ScrollView -> view.isFillViewport = true - is CheckBox -> { - view.buttonTintList = controlTint(view.context) - view.minimumHeight = view.context.dp(48) - view.minimumWidth = view.context.dp(48) - } - is RadioButton -> { - view.buttonTintList = controlTint(view.context) - view.minimumHeight = view.context.dp(48) - view.minimumWidth = view.context.dp(48) - } - is Button -> styleButton(view) - is EditText -> { - view.elevation = 0f - view.typeface = Typeface.create("sans-serif", Typeface.NORMAL) - } - is ImageButton -> view.useModernIconStyle() - is TextView -> styleText(view) - } - if (view is ViewGroup) { - repeat(view.childCount) { index -> applyControls(view.getChildAt(index)) } - } - } - - private fun styleText(textView: TextView) { - if (textView.typeface == Typeface.MONOSPACE) return - val sizeSp = textView.textSize / - (textView.resources.displayMetrics.density * textView.resources.configuration.fontScale) - when { - sizeSp >= 24f && textView.typeface?.isBold == true -> { - textView.typeface = Typeface.create("sans-serif", Typeface.BOLD) - textView.letterSpacing = -0.01f - } - sizeSp >= 18f && textView.typeface?.isBold == true -> { - textView.typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) - textView.letterSpacing = 0f - } - } - } - - private fun prepareScreenSurface(root: View) { - val content = root.findViewById(android.R.id.content) ?: return - val screen = content.getChildAt(0) ?: return - screen.setBackgroundColor(screen.context.getColor(R.color.app_background)) - if (screen is ScrollView) { - screen.isFillViewport = true - screen.clipToPadding = false - } - } - - private fun statefulSurface( - context: Context, - normalColor: Int, - pressedColor: Int, - disabledColor: Int, - normalStroke: Int, - pressedStroke: Int, - radiusDp: Int, - ): Drawable { - return StateListDrawable().apply { - addState( - intArrayOf(-android.R.attr.state_enabled), - shape(context, disabledColor, normalStroke, radiusDp), - ) - addState( - intArrayOf(android.R.attr.state_pressed), - shape(context, pressedColor, pressedStroke, radiusDp), - ) - addState(intArrayOf(), shape(context, normalColor, normalStroke, radiusDp)) - } - } - - private fun shape( - context: Context, - fillColor: Int, - strokeColor: Int, - radiusDp: Int, - ) = GradientDrawable().apply { - setColor(context.getColor(fillColor)) - cornerRadius = context.dp(radiusDp).toFloat() - if (strokeColor != android.R.color.transparent) { - setStroke(context.dp(1), context.getColor(strokeColor)) - } - } - - private fun buttonTextColors(context: Context, enabledColor: Int) = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_enabled), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.text_secondary), - context.getColor(enabledColor), - ), - ) - - private fun controlTint(context: Context) = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_enabled), - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.outline_strong), - context.getColor(R.color.accent), - context.getColor(R.color.outline_strong), - ), - ) - - private data class ButtonColors( - val normal: Int, - val pressed: Int, - val disabled: Int, - val stroke: Int, - val pressedStroke: Int, - val text: Int, - val insetVertical: Int, - val radiusDp: Int = 2, - ) -} - -private class AccessibleActionRow(context: Context) : LinearLayout(context) { - override fun getAccessibilityClassName(): CharSequence = Button::class.java.name -} - -internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - -internal fun ImageButton.useModernIconStyle() { - background = InsetDrawable(ModernUi.panelBackground(context), context.dp(4)) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.primary)) - elevation = 0f -} diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index ccf0aa0..d7b07ab 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -11,17 +11,22 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.Button +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.dp import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout -import android.widget.RadioButton import android.widget.ScrollView import android.widget.TextView import androidx.annotation.DrawableRes import androidx.annotation.StringRes /** One-time, bilingual walkthrough of the app's five user-facing workflows. */ -class OnboardingActivity : LocalizedActivity() { +class OnboardingActivity : BaseActivity() { + override fun supportsPendingPlatoonAdmission(): Boolean = false private lateinit var pageHost: FrameLayout @@ -39,8 +44,26 @@ class OnboardingActivity : LocalizedActivity() { pageIndex = savedInstanceState?.getInt(STATE_PAGE_INDEX) ?.coerceIn(PAGES.indices) ?: 0 - setContentView(buildContent()) + val root = buildContent() + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + view.setPadding(bars.left + dp(20), bars.top + dp(8), bars.right + dp(20), bars.bottom + dp(12)) + WindowInsetsCompat.CONSUMED + } + setContentView(root) renderPage(animate = false) + // System back steps through the pages before leaving onboarding. + onBackPressedDispatcher.addCallback(this, backToPreviousPage) + } + + private val backToPreviousPage = object : androidx.activity.OnBackPressedCallback(false) { + override fun handleOnBackPressed() = previousPage() + } + + private fun previousPage() { + if (pageIndex == 0) return + pageIndex-- + renderPage(animate = true, direction = -1) } override fun onSaveInstanceState(outState: Bundle) { @@ -51,27 +74,29 @@ class OnboardingActivity : LocalizedActivity() { /** Builds the stable header, content host, and bottom navigation frame. */ private fun buildContent(): View = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), dp(12)) + setBackgroundColor(getColor(R.color.app_background)) addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL addView( - OnboardingLanguageToggle(context, LanguagePreferences.get(context)) { language -> + Controls.segmentedControl( + context = context, + options = listOf( + LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english), + LanguagePreferences.KOREAN to getString(R.string.language_korean_native), + ), + selectedValue = LanguagePreferences.get(context), + ) { language -> if (LanguagePreferences.get(this@OnboardingActivity) != language) { LanguagePreferences.set(this@OnboardingActivity, language) recreate() } - }, - LinearLayout.LayoutParams(dp(120), dp(54)), + }.apply { contentDescription = getString(R.string.onboarding_language_toggle) }, + LinearLayout.LayoutParams(dp(176), ViewGroup.LayoutParams.WRAP_CONTENT), ) addView(View(context), LinearLayout.LayoutParams(0, dp(1), 1f)) - skipButton = Button(context).apply { - text = getString(R.string.onboarding_skip) - contentDescription = getString(R.string.onboarding_skip) - useTertiaryActionStyle() - setOnClickListener { finishOnboarding() } - } + skipButton = button(getString(R.string.onboarding_skip), Controls.Role.TERTIARY) { finishOnboarding() } addView(skipButton, LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, dp(48), @@ -87,28 +112,16 @@ class OnboardingActivity : LocalizedActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - backButton = Button(context).apply { - text = getString(R.string.onboarding_back) - useSecondaryActionStyle() - setOnClickListener { - if (pageIndex > 0) { - pageIndex-- - renderPage(animate = true, direction = -1) - } - } - } + backButton = button(getString(R.string.onboarding_back), Controls.Role.SECONDARY) { previousPage() } addView(backButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { marginEnd = dp(6) }) - nextButton = Button(context).apply { - usePrimaryActionStyle() - setOnClickListener { - if (pageIndex == PAGES.lastIndex) { - finishOnboarding() - } else { - pageIndex++ - renderPage(animate = true, direction = 1) - } + nextButton = button("", Controls.Role.PRIMARY) { + if (pageIndex == PAGES.lastIndex) { + finishOnboarding() + } else { + pageIndex++ + renderPage(animate = true, direction = 1) } } addView(nextButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { @@ -141,12 +154,11 @@ class OnboardingActivity : LocalizedActivity() { stepLabel.text = getString(R.string.onboarding_step, pageIndex + 1, PAGES.size) stepLabel.contentDescription = stepLabel.text backButton.isEnabled = pageIndex > 0 + backToPreviousPage.isEnabled = pageIndex > 0 nextButton.text = getString( if (pageIndex == PAGES.lastIndex) R.string.onboarding_finish else R.string.onboarding_next, ) - nextButton.isEnabled = true - nextButton.usePrimaryActionStyle() - skipButton.visibility = View.VISIBLE + skipButton.visibility = if (pageIndex == PAGES.lastIndex) View.INVISIBLE else View.VISIBLE } /** Builds one scroll-safe page from localized product copy and authored icon roles. */ @@ -185,7 +197,7 @@ class OnboardingActivity : LocalizedActivity() { addView(TextView(context).apply { text = if (pageIndex == 0) getString(R.string.app_name) else getString(page.title) gravity = Gravity.CENTER - textSize = 28f + textSize = 26f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { @@ -208,9 +220,9 @@ class OnboardingActivity : LocalizedActivity() { textSize = 13f setTextColor(getColor(R.color.warning_text)) setPadding(dp(12), dp(9), dp(12), dp(9)) - background = ModernUi.panelBackground(context, emphasized = true).apply { + background = GradientDrawable().apply { setColor(getColor(R.color.warning_surface)) - cornerRadius = dp(8).toFloat() + cornerRadius = dp(12).toFloat() } }, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -256,8 +268,10 @@ class OnboardingActivity : LocalizedActivity() { } private fun finishOnboarding() { - OnboardingPreferences.complete(this) - startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + if (!intent.getBooleanExtra(EXTRA_REPLAY, false)) { + OnboardingPreferences.complete(this) + startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + } finish() } @@ -276,6 +290,8 @@ class OnboardingActivity : LocalizedActivity() { ) companion object { + /** Opens the guide from Settings without touching the first-launch completion flag. */ + const val EXTRA_REPLAY = "onboarding.replay" private const val STATE_PAGE_INDEX = "page_index" private val PAGES = listOf( OnboardingPage( @@ -337,84 +353,3 @@ class OnboardingActivity : LocalizedActivity() { ) } } - -/** Two-label segmented control whose selection thumb slides between English and Korean. */ -private class OnboardingLanguageToggle( - context: Context, - initialLanguage: String, - private val onLanguageChanged: (String) -> Unit, -) : FrameLayout(context) { - private val thumb = View(context) - private var selectedLanguage = initialLanguage - private val english = label("English", LanguagePreferences.DEFAULT_LANGUAGE) - private val korean = label("한국어", LanguagePreferences.KOREAN) - - init { - setPadding(dp(3), dp(3), dp(3), dp(3)) - background = ModernUi.panelBackground(context).apply { cornerRadius = dp(12).toFloat() } - thumb.background = ModernUi.panelBackground(context, emphasized = true).apply { - cornerRadius = dp(9).toFloat() - } - addView(thumb) - addView(english) - addView(korean) - post { select(selectedLanguage, false) } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val width = resolveSize(dp(120), widthMeasureSpec) - val height = resolveSize(dp(54), heightMeasureSpec) - setMeasuredDimension(width, height) - val childWidth = (width - paddingLeft - paddingRight) / 2 - val childHeight = height - paddingTop - paddingBottom - val widthSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY) - val heightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY) - repeat(childCount) { index -> getChildAt(index).measure(widthSpec, heightSpec) } - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - val width = right - left - paddingLeft - paddingRight - val height = bottom - top - paddingTop - paddingBottom - val half = width / 2 - thumb.layout(paddingLeft, paddingTop, paddingLeft + half, paddingTop + height) - english.layout(paddingLeft, paddingTop, paddingLeft + half, paddingTop + height) - korean.layout(paddingLeft + half, paddingTop, paddingLeft + width, paddingTop + height) - thumb.translationX = if (selectedLanguage == LanguagePreferences.KOREAN) half.toFloat() else 0f - } - - private fun label(textValue: String, language: String) = RadioButton(context).apply { - text = textValue - buttonDrawable = null - gravity = Gravity.CENTER - textSize = 12f - setTypeface(typeface, Typeface.BOLD) - minimumHeight = dp(48) - isChecked = selectedLanguage == language - setOnClickListener { select(language, true) } - } - - private fun select(language: String, animate: Boolean) { - selectedLanguage = language - val distance = ((width - paddingLeft - paddingRight) / 2).toFloat() - val target = if (language == LanguagePreferences.KOREAN) distance else 0f - english.isChecked = language == LanguagePreferences.DEFAULT_LANGUAGE - korean.isChecked = language == LanguagePreferences.KOREAN - if (animate && ValueAnimator.areAnimatorsEnabled()) { - thumb.animate().cancel() - thumb.animate() - .translationX(target) - .setDuration(140L) - .withEndAction { onLanguageChanged(language) } - .start() - } else { - thumb.translationX = target - if (animate) onLanguageChanged(language) - } - val selectedColor = context.getColor(R.color.accent_text) - val idleColor = context.getColor(R.color.text_primary) - english.setTextColor(if (language == LanguagePreferences.DEFAULT_LANGUAGE) selectedColor else idleColor) - korean.setTextColor(if (language == LanguagePreferences.KOREAN) selectedColor else idleColor) - } - - private fun dp(value: Int): Int = context.dp(value) -} diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 47cdb4c..5f92277 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -1,755 +1,322 @@ package dev.gf2log.app -import android.app.Activity -import android.app.AlertDialog import android.content.Intent -import android.content.res.ColorStateList -import android.database.Cursor -import android.graphics.Typeface -import android.net.Uri -import android.graphics.drawable.GradientDrawable +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.OpenableColumns -import android.text.InputType -import android.view.View -import android.view.ViewGroup -import android.view.Gravity -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText -import android.widget.ImageButton import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast -import dev.gf2log.app.settings.PayloadHistoryPreferences -import dev.gf2log.app.settings.CapturePreferences -import dev.gf2log.app.settings.GameTimeZonePreferences -import dev.gf2log.app.settings.ClientServerRegionPreferences -import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.capture.CaptureDiagnosticsStore -import dev.gf2log.app.capture.CaptureStatus -import dev.gf2log.app.management.BackupFileName -import dev.gf2log.app.management.InvalidBackupException -import dev.gf2log.app.management.PlatoonBackupManager -import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.discord.DiscordWebhookSecretStore -import dev.gf2log.protocol.Gfl2PayloadDecoder -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.LocalDateTime -import java.util.concurrent.Executors +import dev.gf2log.app.settings.CapturePreferences +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.PayloadHistoryPreferences +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.text import dev.gf2log.protocol.PayloadCatalog +import java.time.ZoneId + +/** Settings: grouped rows for the Platoon, display, capture, backup, sharing, and about. */ +class OptionsActivity : BaseActivity() { -class OptionsActivity : LocalizedActivity() { private lateinit var profileBinding: ActivePlatoonScopeBinding - private val mainHandler = Handler(Looper.getMainLooper()) - private val fileIoExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2FullBackup") - } + private val work = BackgroundWork(this, "GF2Settings") + private val backup = BackupFlow(this, work, MAINTENANCE) { profileBinding.scope } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - title = getString(R.string.payload_options) - setContentView(buildContentView()) + backup.restoreState(savedInstanceState) + val screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle(getString(R.string.payload_options)) + screen.setBody(Screen.scrollingBody(this, buildContent())) } - override fun onResume() { - super.onResume() - if (!profileBinding.isCurrent(this)) { - recreate() - return - } - } - - override fun onDestroy() { - fileIoExecutor.shutdownNow() - super.onDestroy() + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) recreate() } - @Deprecated("Uses the platform document picker without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - val uri = data?.data - if (resultCode != Activity.RESULT_OK || uri == null) return - when (requestCode) { - REQUEST_FULL_BACKUP_EXPORT -> { - val scope = requireActiveScope() ?: return - runBackupOperation( - successMessage = R.string.full_backup_exported, - failureMessage = { R.string.full_backup_export_failed }, - ) { - val output = TrustedExportDestination.openOutputStream(contentResolver, uri) - ?: error("Document provider did not open an output stream") - output.use { PlatoonBackupManager(this, scope).exportFull(it) } - } - } - REQUEST_FULL_BACKUP_RESTORE -> { - if (!hasBackupExtension(uri)) { - showBackupMessage(R.string.invalid_full_backup) - return - } - runBackupOperation( - successMessage = R.string.full_backup_restored, - failureMessage = ::fullBackupRestoreFailureMessage, - ) { - val input = TrustedImportSource.openInputStream(contentResolver, uri) - ?: error("Document provider did not open an input stream") - input.use { PlatoonBackupManager.restoreSelected(this, it, complete = true) } - } - } - } + override fun onSaveInstanceState(outState: Bundle) { + backup.saveState(outState) + super.onSaveInstanceState(outState) } - private fun buildContentView(): ScrollView { - val spacing = dp(16) - val preferences = PayloadHistoryPreferences(this) - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, dp(8), spacing, spacing) - - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_arrow_back) - contentDescription = getString(R.string.back) - useModernIconStyle() - setPadding(dp(11), dp(11), dp(11), dp(11)) - setOnClickListener { finish() } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) }) - addView(TextView(context).apply { - text = getString(R.string.payload_options) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - }, matchWidth()) - addView( - PlatoonProfileSelector.controls(this@OptionsActivity), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - - addView(TextView(context).apply { - text = getString(R.string.language) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(10), 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_language_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(6)) - }, matchWidth()) - addView(ModernUi.segmentedControl( - context = context, + private fun buildContent(): LinearLayout = column { + val scope = profileBinding.scope + addView(sectionHeader(getString(R.string.settings_platoon)), lp()) + addView(PlatoonProfileSelector.card(this@OptionsActivity), lp()) + addView(group(listOf( + navigationRow( + getString(R.string.manage_platoon_profiles), + getString(R.string.manage_platoon_profiles_detail), + R.drawable.ic_group, + ) { PlatoonProfileSelector.showManagement(this@OptionsActivity) }, + navigationRow( + getString(R.string.server_region), + scope?.let { resetSummary(it.storageId) } ?: getString(R.string.no_platoon_detected), + R.drawable.ic_calendar, + enabled = scope != null, + ) { scope?.let(::chooseResetRegion) }, + )), lp(top = 8)) + + addView(sectionHeader(getString(R.string.settings_display)), lp()) + addView(panel(padding = 12) { + addView(text(getString(R.string.language), TextRole.LABEL), lp()) + addView(Controls.segmentedControl( + context = this@OptionsActivity, options = listOf( - LanguagePreferences.KOREAN to getString(R.string.language_korean_native), LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english), + LanguagePreferences.KOREAN to getString(R.string.language_korean_native), ), - selectedValue = LanguagePreferences.get(context), - onSelected = ::changeLanguage, - ), matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.appearance) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_appearance_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(6)) - }, matchWidth()) - addView(ModernUi.segmentedControl( - context = context, + selectedValue = LanguagePreferences.get(this@OptionsActivity), + ) { language -> + if (LanguagePreferences.get(this@OptionsActivity) != language) { + LanguagePreferences.set(this@OptionsActivity, language) + recreate() + } + }, lp(top = 6)) + addView(text(getString(R.string.appearance), TextRole.LABEL), lp(top = 16)) + addView(Controls.segmentedControl( + context = this@OptionsActivity, options = listOf( ThemePreferences.SYSTEM to getString(R.string.theme_system), ThemePreferences.LIGHT to getString(R.string.theme_light), ThemePreferences.DARK to getString(R.string.theme_dark), ), - selectedValue = ThemePreferences.get(context), - onSelected = ::changeTheme, - ), matchWidth()) - - addView(TextView(context).apply { - text = getString(R.string.daily_reset_time) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_daily_reset_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(6)) - }, matchWidth()) - val resetScope = profileBinding.scope - val resetRegion = resetScope?.let { GameTimeZonePreferences.region(context, it.storageId) } - addView(ModernUi.listRow( - context = context, - title = getString(R.string.server_region), - detail = if (resetRegion == null) { - getString(R.string.no_platoon_detected) - } else { - resetSummary( - resetRegion, - GameTimeZonePreferences.isAutomatic(context, resetScope.storageId), - ) - }, - icon = R.drawable.ic_calendar, - onClick = { - if (resetScope == null) { - TransientMessage.show(context, R.string.no_platoon_detected_detail) - } else { - chooseGameServerRegion() - } - }, - ), matchWidth()) - addView(ModernUi.listRow( - context = context, - title = getString(R.string.haoplay_capture_region), - detail = clientRegionLabel(SupportedGamePackages.HAOPLAY), - icon = R.drawable.ic_group, - onClick = { chooseClientServerRegion(SupportedGamePackages.HAOPLAY) }, - ), matchWidth()) - addView(ModernUi.listRow( - context = context, - title = getString(R.string.darkwinter_capture_region), - detail = clientRegionLabel(SupportedGamePackages.DARKWINTER), - icon = R.drawable.ic_group, - onClick = { chooseClientServerRegion(SupportedGamePackages.DARKWINTER) }, - ), matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.backup) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_backup_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(3)) - }, matchWidth()) - addView(ModernUi.listRow( - context, - getString(R.string.restore_full_backup), - getString(R.string.restore_full_backup_description), - R.drawable.ic_arrow_back, - ::confirmFullRestore, - ), matchWidth()) - addView(ModernUi.listRow( - context, - getString(R.string.back_up_all_information), - getString(R.string.back_up_all_information_description), - R.drawable.ic_save, - ::exportFullBackup, - ), matchWidth()) - - addView(TextView(context).apply { - text = getString(R.string.discord_webhook) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - val discordIcon = context.getDrawable(R.drawable.ic_discord)?.mutate() - discordIcon?.setTint(currentTextColor) - setCompoundDrawablesRelativeWithIntrinsicBounds( - discordIcon, null, null, null, - ) - compoundDrawablePadding = dp(8) - setPadding(0, spacing, 0, dp(4)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.discord_webhook_description) - textSize = 13f - setPadding(0, 0, 0, dp(8)) - }, matchWidth()) - val webhookStore = DiscordWebhookSecretStore(context) - val webhookConfigured = webhookStore.read() != null - addView(TextView(context).apply { - text = getString( - if (!webhookConfigured) { - R.string.discord_webhook_not_configured - } else { - R.string.discord_webhook_configured - }, - ) - textSize = 16f - setTypeface(typeface, Typeface.BOLD) - val backgroundColor = if (webhookConfigured) { - R.color.webhook_configured_background - } else { - R.color.webhook_missing_background - } - val foregroundColor = if (webhookConfigured) { - R.color.webhook_configured_foreground - } else { - R.color.webhook_missing_foreground - } - setTextColor(context.getColor(foregroundColor)) - background = GradientDrawable().apply { - setColor(context.getColor(backgroundColor)) - cornerRadius = dp(12).toFloat() - } - setPadding(dp(12), dp(10), dp(12), dp(10)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(8) }) - val webhookInput = EditText(context).apply { - hint = getString(R.string.discord_webhook_hint) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - setSingleLine(true) - } - addView(webhookInput, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(4) }) - addView(Button(context).apply { - text = getString(R.string.save_discord_webhook) - usePrimaryActionStyle() - setOnClickListener { - val saved = runCatching { - webhookStore.save(webhookInput.text.toString()) - }.isSuccess - TransientMessage.show( - this@OptionsActivity, - if (saved) { - R.string.discord_webhook_saved - } else { - R.string.discord_webhook_invalid - }, - Toast.LENGTH_LONG, - ) - if (saved) recreate() - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.clear_discord_webhook) - useDestructiveTextActionStyle() - isEnabled = webhookConfigured - setOnClickListener { - val cleared = runCatching(webhookStore::clear).isSuccess - TransientMessage.show( - this@OptionsActivity, - if (cleared) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, - Toast.LENGTH_LONG, - ) - if (cleared) recreate() + selectedValue = ThemePreferences.get(this@OptionsActivity), + ) { mode -> + if (ThemePreferences.get(this@OptionsActivity) != mode) { + ThemePreferences.set(this@OptionsActivity, mode) + recreate() } - }, matchWidth()) - - addView(TextView(context).apply { - text = getString(R.string.payload_history) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - - val capturePreferences = CapturePreferences(context) - addView(packetHistoryOptionRow( - title = getString(R.string.detailed_notifications), - detail = getString(R.string.notification_required_explanation), - checked = capturePreferences.detailedNotifications, - enabled = true, - onChanged = { capturePreferences.detailedNotifications = it }, - ), matchWidth()) - + }, lp(top = 6)) + }, lp()) + + addView(sectionHeader(getString(R.string.settings_capture)), lp()) + addView(caption(getString(R.string.settings_capture_servers_detail)), lp()) + addView(group(listOf(SupportedGamePackages.HAOPLAY, SupportedGamePackages.DARKWINTER).map { packageName -> + navigationRow( + getString(captureRegionTitle(packageName)), + ClientServerRegionPreferences(this@OptionsActivity).configured(packageName) + ?.let { Labels.region(this@OptionsActivity, it) } + ?: getString(R.string.server_region_not_configured), + R.drawable.ic_shield, + ) { chooseCaptureRegion(packageName) } + }), lp()) + val capturePreferences = CapturePreferences(this@OptionsActivity) + val payloads = PayloadHistoryPreferences(this@OptionsActivity) + addView(panel(padding = 8) { + addView(switchRow( + getString(R.string.detailed_notifications), + getString(R.string.notification_required_explanation), + capturePreferences.detailedNotifications, + ) { capturePreferences.detailedNotifications = it }, lp()) + addView(text(getString(R.string.payload_history), TextRole.LABEL) { + setPadding(dp(4), dp(20), dp(4), dp(2)) + }, lp()) + addView(caption(getString(R.string.payload_options_description)), lp()) PayloadCatalog.categories.forEach { category -> - addView(packetHistoryOptionRow( - title = getString( - R.string.payload_option_label, - payloadName(category.payloadType), - category.payloadType, - ), - detail = if (category.isRequired) { - getString( - R.string.required_payload_description, - payloadDescription(category.payloadType), - ) - } else { - payloadDescription(category.payloadType) - }, - checked = preferences.isEnabled(category.payloadType), + addView(switchRow( + title = Labels.payloadName(this@OptionsActivity, category.payloadType), + detail = Labels.payloadDescription(this@OptionsActivity, category.payloadType), + checked = category.isRequired || payloads.isEnabled(category.payloadType), enabled = !category.isRequired, - onChanged = { preferences.setEnabled(category.payloadType, it) }, - ), matchWidth()) + badge = getString(R.string.required).takeIf { category.isRequired }, + ) { payloads.setEnabled(category.payloadType, it) }, lp()) } - - addView(TextView(context).apply { - text = getString(R.string.last_capture_diagnostics) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(4)) - }, matchWidth()) - addView(TextView(context).apply { - val diagnostics = CaptureDiagnosticsStore(context).read() - text = if (diagnostics == null) { - getString(R.string.no_capture_diagnostics) - } else { - getString( - R.string.capture_diagnostics, - diagnostics.stoppedAt.atZone(ZoneId.systemDefault()).format(DIAGNOSTIC_TIME), - diagnostics.forwardedBytes / 1024, - diagnostics.inspectedBytes / 1024, - diagnostics.decodedPayloads, - diagnostics.warnings, - diagnostics.droppedChunks, - diagnostics.unknownPayloads.ifBlank { getString(R.string.none) }, - ) - } - setTextIsSelectable(true) - }, matchWidth()) - } - return ScrollView(this).apply { addView(container, matchWidth()) } - } - - /** Keeps a packet-history choice and its explanation in one compact visual group. */ - private fun packetHistoryOptionRow( - title: CharSequence, - detail: CharSequence, - checked: Boolean, - enabled: Boolean, - onChanged: (Boolean) -> Unit, - ): View = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.TOP - minimumHeight = dp(52) - setPadding(0, dp(2), 0, dp(2)) - val checkBox = CheckBox(context).apply { - contentDescription = title - isChecked = checked - isEnabled = enabled - if (!enabled) { - buttonTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), - intArrayOf(getColor(R.color.primary), getColor(R.color.primary)), + }, lp(top = 8)) + + addView(sectionHeader(getString(R.string.backup)), lp()) + addView(caption(getString(R.string.settings_backup_detail)), lp()) + addView(group(listOf( + navigationRow( + getString(R.string.export_platoon_backup), + getString(R.string.export_platoon_backup_detail), + R.drawable.ic_download, + enabled = scope != null, + ) { backup.export(complete = false) }, + navigationRow( + getString(R.string.back_up_all_information), + getString(R.string.back_up_all_information_description), + R.drawable.ic_save, + enabled = scope != null, + ) { backup.export(complete = true) }, + navigationRow(getString(R.string.restore_full_backup), getString(R.string.restore_backup_detail), R.drawable.ic_upload) { + backup.confirmRestore() + }, + )), lp()) + + addView(sectionHeader(getString(R.string.discord_webhook)), lp()) + addView(DiscordWebhookRow(this@OptionsActivity, work).view, lp()) + + addView(sectionHeader(getString(R.string.settings_about)), lp()) + addView(group(listOf( + navigationRow( + getString(R.string.last_capture_diagnostics), + getString(R.string.last_capture_diagnostics_detail), + R.drawable.ic_bug, + ) { showDiagnostics() }, + navigationRow(getString(R.string.show_welcome_guide), null, R.drawable.ic_info_outline) { + startActivity( + Intent(this@OptionsActivity, OnboardingActivity::class.java).putExtra(OnboardingActivity.EXTRA_REPLAY, true), ) - } - setOnCheckedChangeListener { _, value -> onChanged(value) } - } - addView(checkBox, LinearLayout.LayoutParams(dp(48), dp(48))) - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - addView(TextView(context).apply { - text = title - textSize = 14f - setTextColor(getColor(if (enabled) R.color.text_primary else R.color.text_secondary)) - }, matchWidth()) - addView(TextView(context).apply { - text = detail - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setLineSpacing(0f, 1.08f) - setPadding(0, dp(2), 0, 0) - }, matchWidth()) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - topMargin = dp(4) - }) - if (enabled) { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - setOnClickListener { checkBox.performClick() } - } + }, + navigationRow(getString(R.string.app_version), versionName(), R.drawable.ic_code, showChevron = false) {}, + )), lp()) } - private fun confirmFullRestore() { - if (CaptureStatus.isRunning) { - showBackupMessage(R.string.stop_capture_before_backup) - return - } - android.app.AlertDialog.Builder(this) - .setTitle(R.string.restore_full_backup) - .setMessage(R.string.restore_full_backup_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.restore_full_backup) { _, _ -> openFullBackup() } - .show() - } + private fun caption(value: String) = text(value, TextRole.CAPTION) { setPadding(dp(4), 0, dp(4), dp(8)) } - @Suppress("DEPRECATION") - private fun openFullBackup() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra(Intent.EXTRA_MIME_TYPES, BACKUP_MIME_TYPES) - startActivityForResult(intent, REQUEST_FULL_BACKUP_RESTORE) - } + private fun captureRegionTitle(packageName: String) = + if (packageName == SupportedGamePackages.HAOPLAY) R.string.haoplay_capture_region else R.string.darkwinter_capture_region - @Suppress("DEPRECATION") - private fun exportFullBackup() { - if (requireActiveScope() == null) return - if (CaptureStatus.isRunning) { - showBackupMessage(R.string.stop_capture_before_backup) - return - } - val title = "mobileGF2logger-full-${BACKUP_TIME.format(LocalDateTime.now())}." + - PlatoonBackupManager.FILE_EXTENSION - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(PlatoonBackupManager.MIME_TYPE) - .putExtra(Intent.EXTRA_TITLE, title) - startActivityForResult(intent, REQUEST_FULL_BACKUP_EXPORT) - } + // region Server reset - private fun hasBackupExtension(uri: Uri): Boolean { - val name = contentResolver.query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - )?.use { cursor: Cursor -> - if (!cursor.moveToFirst()) null else cursor.getString(0) - } ?: return false - return BackupFileName.isValid(name) - } - - private fun runBackupOperation( - successMessage: Int, - failureMessage: (Throwable) -> Int, - operation: () -> Unit, - ) { - fileIoExecutor.execute { - val message = runCatching(operation).fold( - onSuccess = { successMessage }, - onFailure = failureMessage, - ) - mainHandler.post { - if (!isFinishing && !isDestroyed) { - showBackupMessage(message) - if (message == R.string.full_backup_restored) recreate() - } - } + private fun resetSummary(storageId: String): String { + val region = GameTimeZonePreferences.region(this, storageId) + val zone = GameTimeZonePreferences.deviceZone() + if (region == GameServerRegion.MANUAL) { + return getString(R.string.manual_reset_region_summary, GameTimeZonePreferences.get(this, storageId).id, zone.id) } - } - - private fun showBackupMessage(message: Int) { - TransientMessage.show(this, message, android.widget.Toast.LENGTH_LONG) - } - - private fun requireActiveScope(): dev.gf2log.app.management.PlatoonStorageScope? = - profileBinding.scope.also { scope -> - if (scope == null) showBackupMessage(R.string.no_platoon_detected_detail) + val resolved = getString( + R.string.server_reset_summary, + Labels.region(this, region), + Labels.format(Labels.TIME, region.nextReset(), zone), + zone.id, + ) + return if (GameTimeZonePreferences.isAutomatic(this, storageId)) { + getString(R.string.auto_server_reset_summary, resolved) + } else { + resolved } - - private fun changeLanguage(language: String) { - if (LanguagePreferences.get(this) == language) return - LanguagePreferences.set(this, language) - recreate() - } - - private fun changeTheme(mode: String) { - if (ThemePreferences.get(this) == mode) return - ThemePreferences.set(this, mode) - recreate() } - private fun chooseGameServerRegion() { - val storageScope = PlatoonProfileRegistry(this).activeScope() + private fun chooseResetRegion(scope: PlatoonStorageScope) { val regions = GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL } - val choices = listOf(getString(R.string.server_region_auto)) + regions.map(::regionLabel) - val automatic = GameTimeZonePreferences.isAutomatic(this, storageScope.storageId) - val current = GameTimeZonePreferences.region(this, storageScope.storageId) + val automatic = GameTimeZonePreferences.isAutomatic(this, scope.storageId) + val current = GameTimeZonePreferences.region(this, scope.storageId) val checked = if (automatic) 0 else regions.indexOf(current).takeIf { it >= 0 }?.plus(1) ?: -1 - AlertDialog.Builder(this) - .setTitle(R.string.server_region) - .setSingleChoiceItems( - choices.toTypedArray(), - checked, - ) { dialog, which -> - dialog.dismiss() - if (which == 0) { - if (!automatic) { - updateGameTimeZone(storageScope, null) - } - } else { - val selected = regions[which - 1] - if (automatic || selected != current) updateGameTimeZone(storageScope, selected) - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun chooseClientServerRegion(packageName: String) { - val preferences = ClientServerRegionPreferences(this) - val regions = preferences.allowed(packageName) - val current = preferences.configured(packageName) - AlertDialog.Builder(this) - .setTitle( - if (packageName == SupportedGamePackages.HAOPLAY) { - R.string.haoplay_capture_region - } else { - R.string.darkwinter_capture_region - }, - ) - .setSingleChoiceItems( - regions.map(::regionLabel).toTypedArray(), - regions.indexOf(current), - ) { dialog, which -> - preferences.set(packageName, regions[which]) - dialog.dismiss() - recreate() - } - .setNegativeButton(android.R.string.cancel, null) - .show() + Dialogs.choose( + this, + getString(R.string.server_region), + listOf(getString(R.string.server_region_auto)) + regions.map { Labels.region(this, it) }, + checked, + ) { which -> + val selected = if (which == 0) null else regions[which - 1] + val changed = if (selected == null) !automatic else automatic || selected != current + if (changed) updateResetRegion(scope, selected) + } } - private fun updateGameTimeZone( - storageScope: PlatoonStorageScope, - region: GameServerRegion?, - ) { - fileIoExecutor.execute { - val previousAutomatic = GameTimeZonePreferences.isAutomatic(this, storageScope.storageId) - val previousRegion = GameTimeZonePreferences.region(this, storageScope.storageId) - val result = runCatching { + private fun updateResetRegion(scope: PlatoonStorageScope, region: GameServerRegion?) { + if (work.isBusy(MAINTENANCE)) return + Feedback.show(this, R.string.rebuilding_weekly_history) + work.run( + key = MAINTENANCE, + task = { + val previousAutomatic = GameTimeZonePreferences.isAutomatic(this, scope.storageId) + val previousRegion = GameTimeZonePreferences.region(this, scope.storageId) if (region == null) { - GameTimeZonePreferences.clearRegionOverride(this, storageScope.storageId) + GameTimeZonePreferences.clearRegionOverride(this, scope.storageId) } else { - GameTimeZonePreferences.setRegion(this, region, storageScope.storageId) + GameTimeZonePreferences.setRegion(this, region, scope.storageId) } - val zoneId = GameTimeZonePreferences.get(this, storageScope.storageId) try { - PlatoonRepository(this, storageScope) - .rebuildWeeklyHistoryForTimeZoneChange(zoneId) + PlatoonRepository(this, scope) + .rebuildWeeklyHistoryForTimeZoneChange(GameTimeZonePreferences.get(this, scope.storageId)) } catch (error: Exception) { runCatching { if (previousAutomatic) { - GameTimeZonePreferences.clearRegionOverride(this, storageScope.storageId) + GameTimeZonePreferences.clearRegionOverride(this, scope.storageId) } else { - GameTimeZonePreferences.setRegion( - this, - previousRegion, - storageScope.storageId, - ) + GameTimeZonePreferences.setRegion(this, previousRegion, scope.storageId) } }.exceptionOrNull()?.let(error::addSuppressed) throw error } - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - TransientMessage.show( - this, - if (result.isSuccess) R.string.game_timezone_updated - else R.string.game_timezone_update_failed, - Toast.LENGTH_SHORT, - ) - recreate() - } + }, + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.game_timezone_updated else R.string.game_timezone_update_failed) + recreate() } } - private fun resetSummary(region: GameServerRegion, automatic: Boolean): String { - if (region == GameServerRegion.MANUAL) { - return getString( - R.string.manual_reset_region_summary, - GameTimeZonePreferences.get(this).id, - GameTimeZonePreferences.deviceZone().id, - ) + private fun chooseCaptureRegion(packageName: String) { + val preferences = ClientServerRegionPreferences(this) + val regions = preferences.allowed(packageName) + Dialogs.choose( + this, + getString(captureRegionTitle(packageName)), + regions.map { Labels.region(this, it) }, + regions.indexOf(preferences.configured(packageName)), + ) { which -> + preferences.set(packageName, regions[which]) + recreate() } - val localReset = region.nextReset() - .atZone(GameTimeZonePreferences.deviceZone()) - .format(RESET_LOCAL_TIME) - val resolved = getString( - R.string.server_reset_summary, - regionLabel(region), - localReset, - GameTimeZonePreferences.deviceZone().id, - ) - return if (automatic) getString(R.string.auto_server_reset_summary, resolved) else resolved } - private fun regionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun clientRegionLabel(packageName: String): String = - ClientServerRegionPreferences(this).configured(packageName) - ?.let(::regionLabel) - ?: getString(R.string.server_region_not_configured) - - private fun payloadName(payloadType: Int): String = getString( - when (payloadType) { - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> R.string.payload_name_platoon_profile - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_name_platoon_members - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> R.string.payload_name_platoon_activity - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> R.string.payload_name_platoon_updates - Gfl2PayloadDecoder.TYPE_WEAPONS -> R.string.payload_name_weapons - Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> R.string.payload_name_attachments - Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> R.string.payload_name_common_keys - Gfl2PayloadDecoder.TYPE_FORMATIONS -> R.string.payload_name_formations - else -> R.string.unknown_payload_type - }, - ) - - private fun payloadDescription(payloadType: Int): String = getString( - when (payloadType) { - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> - R.string.payload_description_platoon_profile - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_description_platoon_members - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> - R.string.payload_description_platoon_activity - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> - R.string.payload_description_platoon_updates - Gfl2PayloadDecoder.TYPE_WEAPONS -> R.string.payload_description_weapons - Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> R.string.payload_description_attachments - Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> R.string.payload_description_common_keys - Gfl2PayloadDecoder.TYPE_FORMATIONS -> R.string.payload_description_formations - else -> R.string.unknown_payload_type - }, - ) + // endregion + + // region Diagnostics and about + + private fun showDiagnostics() { + work.run(task = { CaptureDiagnosticsStore(this).read() }) { result -> + val diagnostics = result.getOrNull() + val message = if (diagnostics == null) { + getString(R.string.no_capture_diagnostics) + } else { + getString( + R.string.capture_diagnostics, + Labels.format(Labels.DATE_TIME_SECONDS, diagnostics.stoppedAt, ZoneId.systemDefault()), + diagnostics.forwardedBytes / 1024, + diagnostics.inspectedBytes / 1024, + diagnostics.decodedPayloads, + diagnostics.warnings, + diagnostics.droppedChunks, + diagnostics.unknownPayloads.ifBlank { getString(R.string.none) }, + ) + } + val builder = Dialogs.builder(this) + .setTitle(R.string.last_capture_diagnostics) + .setMessage(message) + .setPositiveButton(R.string.done, null) + if (diagnostics != null) { + builder.setNeutralButton(R.string.copy) { _, _ -> + Feedback.copy(this, getString(R.string.last_capture_diagnostics), message, R.string.copied) + } + } + Dialogs.show(builder) + } + } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + private fun versionName(): String = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)).versionName + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0).versionName + } + }.getOrNull().orEmpty() - private fun matchWidth(): ViewGroup.LayoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + // endregion - companion object { - private val DIAGNOSTIC_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") - private val RESET_LOCAL_TIME = DateTimeFormatter.ofPattern("HH:mm") - private val BACKUP_TIME = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") - private val BACKUP_MIME_TYPES = arrayOf( - PlatoonBackupManager.MIME_TYPE, - "application/zip", - "application/octet-stream", - ) - private const val REQUEST_FULL_BACKUP_EXPORT = 301 - private const val REQUEST_FULL_BACKUP_RESTORE = 302 + private companion object { + const val MAINTENANCE = "maintenance" } } - -// Function Name: fullBackupRestoreFailureMessage -// Description: -// - Keeps known backup validation failures distinct from operational restore failures. -// - Prevents storage, document-provider, and unexpected errors from blaming the selected file. -// Parameters: -// - error: Failure raised while opening, validating, or restoring a complete backup. -// Returns: -// - Returns the invalid-backup message for validation failures. -// - Returns the operational restore-failure message for every other failure. -internal fun fullBackupRestoreFailureMessage(error: Throwable): Int = - if (error is InvalidBackupException) { - R.string.invalid_full_backup - } else { - R.string.full_backup_restore_failed - } diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 799e27b..d27a9e2 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -1,292 +1,201 @@ package dev.gf2log.app -import android.app.AlertDialog import android.content.Intent -import android.content.res.ColorStateList -import android.content.ClipData -import android.content.ClipboardManager import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.Button import android.widget.FrameLayout import android.widget.HorizontalScrollView -import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TableLayout import android.widget.TableRow -import android.widget.TextView -import android.widget.Toast -import dev.gf2log.app.history.CaptureHistoryStore -import dev.gf2log.app.history.SavedHistoryStore import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.app.discord.DiscordWebhookSender import dev.gf2log.app.discord.OriginalCsvPayload -import java.util.concurrent.Executors -import dev.gf2log.protocol.ParsedPacketTableParser -import java.io.File +import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.history.SavedHistoryStore import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.text +import dev.gf2log.protocol.ParsedPacketTableParser +import java.io.File + +/** One parsed packet as a bounded table or its complete decoded text. */ +class PacketHistoryActivity : BaseActivity() { -class PacketHistoryActivity : LocalizedActivity() { - private lateinit var actionButton: Button - private lateinit var contentContainer: LinearLayout - private lateinit var sendButton: Button - private lateinit var sendUnavailableOverlay: View - private lateinit var rawContent: String + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2PacketHistory") + private val body by lazy { FrameLayout(this) } + private var rawContent: String? = null private var table: ParsedPacketTableParser.Table? = null private var showingRaw = false - private val sendExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2DiscordWebhook") - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val entryId = intent.getStringExtra(EXTRA_ENTRY_ID).orEmpty() - val title = intent.getStringExtra(EXTRA_ENTRY_TITLE).orEmpty() val storageId = intent.getStringExtra(EXTRA_STORAGE_ID) - val active = PlatoonProfileRegistry(this).active() - if (storageId == null || active?.storageId != storageId) { + if (storageId == null || PlatoonProfileRegistry(this).active()?.storageId != storageId) { finish() return } - val historyRoot = PlatoonStorageScope(storageId).rootDirectory(this) - rawContent = if (intent.getBooleanExtra(EXTRA_SAVED_ENTRY, false)) { - SavedHistoryStore( - File(historyRoot, SavedHistoryStore.SAVED_HISTORY_DIRECTORY), - ).read(entryId) - } else { - CaptureHistoryStore( - File(historyRoot, CaptureHistoryStore.HISTORY_DIRECTORY), - ).read(entryId) - } ?: run { - finish() - return - } - table = ParsedPacketTableParser.parse(rawContent) - - val spacing = (16 * resources.displayMetrics.density).toInt() - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, spacing, spacing, spacing) - addView(TextView(context).apply { - text = title - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - actionButton = Button(context).apply { - useSecondaryActionStyle() - setOnClickListener { - if (showingRaw) copyToClipboard(rawContent) else showRawContent() - } + showingRaw = savedInstanceState?.getBoolean(STATE_RAW) == true + screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle(intent.getStringExtra(EXTRA_ENTRY_TITLE).orEmpty()) + screen.setBody(column { + addView(body, lp(height = 0, weight = 1f)) + }) + body.addView(StateViews.loading(this, getString(R.string.loading_packet))) + val root = PlatoonStorageScope(storageId).rootDirectory(this) + val saved = intent.getBooleanExtra(EXTRA_SAVED_ENTRY, false) + work.run( + task = { + val content = if (saved) { + SavedHistoryStore(File(root, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)).read(entryId) + } else { + CaptureHistoryStore(File(root, CaptureHistoryStore.HISTORY_DIRECTORY)).read(entryId) } - addView(actionButton, LinearLayout.LayoutParams( - 0, - dp(48), - 1f, - ).apply { marginEnd = dp(4) }) - sendButton = Button(context).apply { - text = getString(R.string.send_original_csv) - usePrimaryActionStyle() - setCompoundDrawablesRelativeWithIntrinsicBounds( - R.drawable.ic_discord, 0, 0, 0, - ) - compoundDrawableTintList = ColorStateList.valueOf( - context.getColor(R.color.primary_action_foreground), - ) - compoundDrawablePadding = dp(6) - setOnClickListener { confirmSendOriginalCsv() } - } - addView(FrameLayout(context).apply { - addView( - sendButton, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - sendUnavailableOverlay = View(context).apply { - isClickable = true - isFocusable = true - contentDescription = getString(R.string.discord_webhook_not_configured) - setOnClickListener { showWebhookRequiredDialog() } - } - addView( - sendUnavailableOverlay, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - }, LinearLayout.LayoutParams( - 0, - dp(48), - 1f, - ).apply { marginStart = dp(4) }) - }, matchWidth()) - contentContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, spacing, 0, 0) + content?.let { it to ParsedPacketTableParser.parse(it) } + }, + ) { result -> + val loaded = result.getOrNull() + if (loaded == null) { + body.removeAllViews() + body.addView(StateViews.empty(this, R.drawable.ic_warning, getString(R.string.packet_unavailable))) + return@run } - addView(contentContainer, matchWidth()) + rawContent = loaded.first + table = loaded.second + if (table == null) showingRaw = true + render() } - - setContentView(ScrollView(this).apply { addView(container, matchWidth()) }) - if (table == null) showRawContent() else showTable() - refreshSendAvailability() } - - override fun onResume() { - super.onResume() - if (::sendButton.isInitialized) refreshSendAvailability() - } - - private fun refreshSendAvailability() { - val hasCsvTable = table != null - val webhookConfigured = runCatching { - DiscordWebhookSecretStore(this).read() != null - }.getOrDefault(false) - val unavailable = hasCsvTable && !webhookConfigured - sendButton.isEnabled = hasCsvTable && webhookConfigured - sendButton.alpha = if (sendButton.isEnabled) 1f else 0.5f - sendUnavailableOverlay.visibility = if (unavailable) View.VISIBLE else View.GONE - } - - private fun showWebhookRequiredDialog() { - AlertDialog.Builder(this) - .setTitle(R.string.discord_webhook_not_configured) - .setMessage(R.string.discord_webhook_required) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.open_options) { _, _ -> - startActivity(Intent(this, OptionsActivity::class.java)) - } - .show() + override fun onScreenResumed() { + // The entry belongs to the Platoon it was opened from; a switch elsewhere closes it. + if (PlatoonProfileRegistry(this).active()?.storageId != intent.getStringExtra(EXTRA_STORAGE_ID)) finish() } - override fun onDestroy() { - sendExecutor.shutdownNow() - super.onDestroy() + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_RAW, showingRaw) + super.onSaveInstanceState(outState) } - private fun confirmSendOriginalCsv() { - val csv = runCatching { OriginalCsvPayload.extract(rawContent) }.getOrElse { - TransientMessage.show(this, R.string.discord_csv_invalid, Toast.LENGTH_LONG) - return - } - val webhook = DiscordWebhookSecretStore(this).read() ?: run { - showWebhookRequiredDialog() - return - } - AlertDialog.Builder(this) - .setTitle(R.string.send_original_csv) - .setMessage(R.string.send_original_csv_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.send_original_csv) { _, _ -> - sendOriginalCsv(webhook, csv) + private fun render() { + val content = rawContent ?: return + val hasTable = table != null + screen.topBar.setActions( + listOf(BarAction(R.drawable.ic_copy, getString(R.string.copy_parsed_packet)) { copy(content) }), + overflow = listOf(MenuEntry(getString(R.string.send_original_csv), hasTable) { confirmSend(content) }), + ) + body.removeAllViews() + body.addView(column { + if (hasTable) { + addView(Controls.segmentedControl( + context = this@PacketHistoryActivity, + options = listOf(TABLE to getString(R.string.packet_view_table), RAW to getString(R.string.packet_view_raw)), + selectedValue = if (showingRaw) RAW else TABLE, + ) { + showingRaw = it == RAW + render() + }, lp(start = 16, end = 16, top = 4, bottom = 8)) } - .show() + addView(if (showingRaw) rawView(content) else tableView(requireNotNull(table)), lp(height = 0, weight = 1f)) + }, FrameLayout.LayoutParams(MATCH, MATCH)) } - // Function Name: sendOriginalCsv - // Description: - // - Sends only the validated original CSV body after explicit confirmation. - // - Runs bounded HTTPS I/O away from the main thread and never logs the secret URL. - // Parameters: - // - webhook: Decrypted, policy-validated Discord incoming-webhook URL. - // - csv: Validated original CSV body with history metadata removed. - // Returns: - // - Unit after scheduling the request and result notification. - private fun sendOriginalCsv(webhook: String, csv: String) { - sendButton.alpha = 0.5f - sendButton.isEnabled = false - TransientMessage.show(this, R.string.discord_csv_sending) - sendExecutor.execute { - val sent = runCatching { DiscordWebhookSender().send(webhook, csv) }.isSuccess - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - refreshSendAvailability() - TransientMessage.show( - this, - if (sent) R.string.discord_csv_sent else R.string.discord_csv_send_failed, - Toast.LENGTH_LONG, - ) - } - } + private fun rawView(content: String) = ScrollView(this).apply { + clipToPadding = false + setPadding(dp(16), 0, dp(16), dp(24)) + addView(text(content, TextRole.BODY) { + typeface = Typeface.MONOSPACE + textSize = 12f + setTextIsSelectable(true) + }) } - private fun showTable() { - val parsed = table ?: return - showingRaw = false - actionButton.text = getString(R.string.show_raw_csv_file) - contentContainer.removeAllViews() - contentContainer.addView(HorizontalScrollView(this).apply { - isFillViewport = true + private fun tableView(parsed: ParsedPacketTableParser.Table) = ScrollView(this).apply { + clipToPadding = false + setPadding(0, 0, 0, dp(24)) + addView(HorizontalScrollView(context).apply { + clipToPadding = false + setPadding(dp(16), 0, dp(16), 0) addView(TableLayout(context).apply { - isStretchAllColumns = false + setBackgroundColor(getColor(R.color.outline)) + setPadding(1, 1, 1, 1) addView(tableRow(parsed.header, header = true)) parsed.rows.forEach { addView(tableRow(it, header = false)) } }) - }, matchWidth()) - } - - private fun showRawContent() { - showingRaw = true - actionButton.text = getString(R.string.copy_parsed_packet) - contentContainer.removeAllViews() - contentContainer.addView(TextView(this).apply { - text = rawContent - textSize = 13f - typeface = Typeface.MONOSPACE - setTextIsSelectable(true) - }, matchWidth()) + }, FrameLayout.LayoutParams(MATCH, WRAP)) } private fun tableRow(values: List, header: Boolean): TableRow = TableRow(this).apply { values.forEach { value -> - addView(TextView(context).apply { - text = value - textSize = if (header) 14f else 13f + addView(text(value, if (header) TextRole.LABEL else TextRole.BODY) { + if (header) setTextColor(getColor(R.color.text_primary)) + textSize = 13f gravity = Gravity.CENTER_VERTICAL - setTextColor(getColor(R.color.text_primary)) - if (header) setTypeface(typeface, Typeface.BOLD) - val horizontal = (10 * resources.displayMetrics.density).toInt() - val vertical = (8 * resources.displayMetrics.density).toInt() - setPadding(horizontal, vertical, horizontal, vertical) - background = GradientDrawable().apply { - setColor(getColor(if (header) R.color.table_header else R.color.table_cell)) - setStroke(1, getColor(R.color.outline_strong)) - } - }, TableRow.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) + setPadding(dp(10), dp(8), dp(10), dp(8)) + setBackgroundColor(getColor(if (header) R.color.table_header else R.color.table_cell)) + setTextIsSelectable(!header) + }, TableRow.LayoutParams(WRAP, MATCH).apply { setMargins(0, 0, 1, 1) }) } } - private fun copyToClipboard(content: String) { - getSystemService(ClipboardManager::class.java) - .setPrimaryClip(ClipData.newPlainText(getString(R.string.clipboard_label), content)) - TransientMessage.show(this, R.string.status_packet_copied) + private fun copy(content: String) { + Feedback.copy(this, getString(R.string.clipboard_label), content, R.string.status_packet_copied) } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private fun matchWidth(): ViewGroup.LayoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private fun confirmSend(content: String) { + val csv = runCatching { OriginalCsvPayload.extract(content) }.getOrElse { + Feedback.show(this, R.string.discord_csv_invalid, long = true) + return + } + work.run(task = { DiscordWebhookSecretStore(this).read() }) { result -> + val webhook = result.getOrNull() + if (webhook == null) { + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.discord_webhook_not_configured) + .setMessage(R.string.discord_webhook_required) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.open_options) { _, _ -> + startActivity(Intent(this, OptionsActivity::class.java)) + }, + ) + return@run + } + Dialogs.confirm(this, R.string.send_original_csv, R.string.send_original_csv_warning, R.string.send) confirm@{ + if (work.isBusy(SEND)) return@confirm + Feedback.show(this, R.string.discord_csv_sending) + work.run(key = SEND, task = { DiscordWebhookSender().send(webhook, csv) }) { sent -> + Feedback.show(this, if (sent.isSuccess) R.string.discord_csv_sent else R.string.discord_csv_send_failed, long = true) + } + } + } + } companion object { const val EXTRA_ENTRY_ID = "entry_id" const val EXTRA_ENTRY_TITLE = "entry_title" const val EXTRA_SAVED_ENTRY = "saved_entry" const val EXTRA_STORAGE_ID = "storage_id" + private const val TABLE = "table" + private const val SEND = "send" + private const val RAW = "raw" + private const val STATE_RAW = "packet.raw" } } diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt index 7443e68..4c462c4 100644 --- a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -3,215 +3,179 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog import android.content.Intent -import android.graphics.Typeface import android.text.TextUtils -import android.view.Gravity -import android.view.ViewGroup -import android.widget.LinearLayout +import android.view.View import android.widget.RadioButton import android.widget.RadioGroup -import android.widget.TextView +import dev.gf2log.app.capture.CaptureNotice +import dev.gf2log.app.capture.CaptureSnapshot +import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService +import dev.gf2log.app.capture.format import dev.gf2log.app.capture.PendingPlatoonAdmissionStore import dev.gf2log.app.management.PlatoonClient import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.text import java.lang.ref.WeakReference -/** Presents the process-memory admission gate without moving capture policy into an Activity. */ +/** + * Presents the process-memory admission gate for a newly detected Platoon. Screens observe the + * pending store, so the prompt appears as soon as a candidate arrives and closes when it is + * confirmed or discarded elsewhere. + */ internal object PendingPlatoonAdmissionPrompt { - private var visibleDialog = WeakReference(null) + private var visible = WeakReference(null) + private var owner = WeakReference(null) + private var visibleToken: String? = null + private val selections = HashMap() - fun showIfNeeded(activity: Activity) { + fun onCandidatesChanged(activity: Activity, candidates: List) { if (activity.isFinishing || activity.isDestroyed) return - if (visibleDialog.get()?.isShowing == true) return - val candidate = PendingPlatoonAdmissionStore.summaries().firstOrNull() ?: return - show(activity, candidate) + val showing = visible.get()?.takeIf { it.isShowing } + if (showing != null) { + if (candidates.any { it.token == visibleToken }) return + // The visible candidate was resolved; move on to the next queued one. + showing.dismiss() + clearVisible() + } + selections.keys.retainAll(candidates.mapTo(mutableSetOf()) { it.token }) + candidates.firstOrNull()?.let { show(activity, it) } + } + + /** Dismisses the prompt only when [activity] owns it, so a newly started screen keeps its own. */ + fun dismiss(activity: Activity) { + if (owner.get() !== activity) return + visible.get()?.dismiss() + clearVisible() + } + + private fun clearVisible() { + visible.clear() + owner.clear() + visibleToken = null } - private fun show( - activity: Activity, - candidate: PendingPlatoonAdmissionStore.Summary, - ) { + private fun show(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { val client = PlatoonClient.fromPackage(candidate.ownerPackage) ?: return val regions = ClientServerRegionPreferences.allowedFor(candidate.ownerPackage) - var selected: GameServerRegion? = null - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(activity.dp(20), activity.dp(4), activity.dp(20), 0) - addView(TextView(context).apply { - text = activity.getString(R.string.new_platoon_detected_message) - textSize = 14f - setTextColor(context.getColor(R.color.text_secondary)) - }, matchWidth()) - addView(profilePanel(activity, candidate, client), matchWidth().apply { - topMargin = activity.dp(16) - bottomMargin = activity.dp(12) - }) - addView(TextView(context).apply { - text = activity.getString(R.string.choose_server_region) - textSize = 15f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }, matchWidth()) - addView(RadioGroup(context).apply { - orientation = RadioGroup.VERTICAL - regions.forEach { region -> - addView(RadioButton(context).apply { - id = android.view.View.generateViewId() - text = activity.serverRegionLabel(region) - textSize = 14f - minimumHeight = activity.dp(48) - setOnCheckedChangeListener { _, checked -> - if (checked) selected = region - } - }, matchWidth()) - } - }, matchWidth().apply { topMargin = activity.dp(4) }) - addView(TextView(context).apply { - text = activity.getString(R.string.new_platoon_memory_only_notice) - textSize = 12f - setTextColor(context.getColor(R.color.text_secondary)) - setPadding(0, activity.dp(8), 0, 0) - }, matchWidth()) + // The server chosen in Settings is preselected; the user still confirms it explicitly. + if (candidate.token !in selections) { + ClientServerRegionPreferences(activity).configured(candidate.ownerPackage) + ?.let { selections[candidate.token] = it } } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.new_platoon_detected) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.confirm, null) - .setCancelable(false) - .create() - visibleDialog = WeakReference(dialog) - dialog.setOnShowListener { - val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - isEnabled = false - usePrimaryActionStyle() + val radios = RadioGroup(activity).apply { orientation = RadioGroup.VERTICAL } + val status = activity.text(null, TextRole.CAPTION) { + visibility = View.GONE + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.new_platoon_detected_message), TextRole.SECONDARY), activity.lp()) + addView(activity.panel(padding = 12) { + addView(activity.text(candidate.profile.platoonName, TextRole.TITLE) { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text( + activity.getString(R.string.pending_platoon_identity, client.displayName, candidate.profile.platoonId.toLong()), + TextRole.CAPTION, + ), activity.lp(top = 2)) + }, activity.lp(top = 12)) + addView(activity.text(activity.getString(R.string.choose_server_region), TextRole.SUBTITLE), activity.lp(top = 16)) + addView(radios, activity.lp(top = 4)) + addView(status, activity.lp(top = 8)) + addView(activity.text(activity.getString(R.string.new_platoon_memory_only_notice), TextRole.CAPTION), activity.lp(top = 8)) + } + var statusListener: ((CaptureSnapshot) -> Unit)? = null + val dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.new_platoon_detected) + .setView(content) + .setCancelable(false) + .setNegativeButton(R.string.discard, null) + .setPositiveButton(R.string.confirm, null), + ) { shown -> + val confirm = shown.getButton(AlertDialog.BUTTON_POSITIVE) + val ids = regions.map { region -> + RadioButton(activity).apply { + id = View.generateViewId() + text = Labels.region(activity, region) + textSize = 14f + setTextColor(activity.getColor(R.color.text_primary)) + buttonTintList = Controls.controlTint(activity) + minimumHeight = activity.dp(48) + isChecked = selections[candidate.token] == region + }.also { radios.addView(it) }.id } - val radioGroup = content.getChildAt(3) as RadioGroup - radioGroup.setOnCheckedChangeListener { _, checkedId -> - val index = (0 until radioGroup.childCount) - .indexOfFirst { radioGroup.getChildAt(it).id == checkedId } - selected = regions.getOrNull(index) - confirm.isEnabled = selected != null + confirm.isEnabled = candidate.token in selections + radios.setOnCheckedChangeListener { _, checkedId -> + regions.getOrNull(ids.indexOf(checkedId))?.let { selections[candidate.token] = it } + confirm.isEnabled = candidate.token in selections } confirm.setOnClickListener { - val region = selected ?: return@setOnClickListener - if (!PendingPlatoonAdmissionStore.contains(candidate.token)) { - dialog.dismiss() - showIfNeeded(activity) - return@setOnClickListener - } + val region = selections[candidate.token] ?: return@setOnClickListener + val requestedAt = System.currentTimeMillis() + status.text = activity.getString(R.string.confirming_platoon) + status.setTextColor(activity.getColor(R.color.text_secondary)) + status.visibility = View.VISIBLE + // Failures arrive only as capture notices; surface them here and allow a retry. + statusListener?.let(CaptureStatus::removeListener) + statusListener = { snapshot: CaptureSnapshot -> + val event = snapshot.notice + if (event != null && event.atMillis >= requestedAt && event.notice in ADMISSION_FAILURES) { + status.text = event.format(activity) + status.setTextColor(activity.getColor(R.color.destructive_action)) + confirm.isEnabled = true + } + }.also(CaptureStatus::addListener) activity.startService( Intent(activity, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_CONFIRM_PENDING_PLATOON) .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token) .putExtra(CaptureVpnService.EXTRA_SERVER_REGION, region.storedValue), ) - dialog.dismiss() - refreshAfterAdmission(activity, candidate.token) - } - dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - confirmDiscard(activity, dialog, candidate) + confirm.isEnabled = false } + shown.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { confirmDiscard(activity, candidate) } } dialog.setOnDismissListener { - if (visibleDialog.get() === dialog) visibleDialog.clear() + statusListener?.let(CaptureStatus::removeListener) + if (visible.get() === dialog) clearVisible() } - dialog.show() - } - - private fun profilePanel( - activity: Activity, - candidate: PendingPlatoonAdmissionStore.Summary, - client: PlatoonClient, - ) = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - background = ModernUi.panelBackground(context) - setPadding(activity.dp(14), activity.dp(12), activity.dp(14), activity.dp(12)) - addView(TextView(context).apply { - text = candidate.profile.platoonName - textSize = 17f - typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, matchWidth()) - addView(TextView(context).apply { - text = activity.getString( - R.string.pending_platoon_identity, - client.displayName, - candidate.profile.platoonId.toLong(), - ) - textSize = 13f - setTextColor(context.getColor(R.color.text_secondary)) - setPadding(0, activity.dp(4), 0, 0) - }, matchWidth()) + visible = WeakReference(dialog) + owner = WeakReference(activity) + visibleToken = candidate.token } - private fun confirmDiscard( - activity: Activity, - parent: AlertDialog, - candidate: PendingPlatoonAdmissionStore.Summary, - ) { - val warning = AlertDialog.Builder(activity) - .setTitle(R.string.discard_pending_platoon_title) - .setMessage(R.string.discard_pending_platoon_message) - .setNegativeButton(R.string.keep_choosing, null) - .setPositiveButton(R.string.discard, null) - .create() - warning.setOnShowListener { - warning.getButton(AlertDialog.BUTTON_POSITIVE).apply { - useDestructiveActionStyle() - setOnClickListener { + private fun confirmDiscard(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.discard_pending_platoon_title) + .setMessage(R.string.discard_pending_platoon_message) + .setNegativeButton(R.string.keep_choosing, null) + .setPositiveButton(R.string.discard) { _, _ -> activity.startService( Intent(activity, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_DISCARD_PENDING_PLATOON) .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token), ) - warning.dismiss() - parent.dismiss() - parent.window?.decorView?.postDelayed( - { showIfNeeded(activity) }, - PROMPT_REFRESH_DELAY_MILLIS, - ) - } - } - } - warning.show() - } - - private fun refreshAfterAdmission(activity: Activity, token: String, attempt: Int = 0) { - activity.window.decorView.postDelayed( - { - if (activity.isFinishing || activity.isDestroyed) return@postDelayed - if (!PendingPlatoonAdmissionStore.contains(token)) { - activity.recreate() - } else if (attempt < MAX_REFRESH_ATTEMPTS) { - refreshAfterAdmission(activity, token, attempt + 1) - } else { - showIfNeeded(activity) - } - }, - PROMPT_REFRESH_DELAY_MILLIS, + }, + destructive = true, ) } - private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun matchWidth() = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, + private val ADMISSION_FAILURES = setOf( + CaptureNotice.ADMISSION_INVALID, + CaptureNotice.ADMISSION_REGION_MISMATCH, + CaptureNotice.ADMISSION_LIMIT, + CaptureNotice.ADMISSION_PROFILE_FAILED, + CaptureNotice.ADMISSION_REPLAY_FAILED, ) - - private const val PROMPT_REFRESH_DELAY_MILLIS = 250L - private const val MAX_REFRESH_ATTEMPTS = 20 } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index b088957..b8495f4 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -1,452 +1,414 @@ package dev.gf2log.app -import android.app.AlertDialog import android.content.Intent -import android.graphics.Typeface +import android.content.res.ColorStateList import android.os.Bundle -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.util.Log +import android.text.TextUtils +import android.view.Gravity +import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Spinner import android.widget.TextView -import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.MemberStatus -import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonMemberCsv +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonSnapshot import dev.gf2log.app.management.SnapshotMember -import dev.gf2log.app.management.isValidMembershipRange -import dev.gf2log.app.settings.GameTimeZonePreferences -import java.time.format.DateTimeFormatter -import java.util.concurrent.Executors +import dev.gf2log.app.ui.searchField +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import java.time.LocalDateTime +import java.time.ZoneId + +/** Platoon roster: search, filter, and sort members; long-press starts selection for export. */ +class PlatoonActivity : BaseActivity() { -class PlatoonActivity : LocalizedActivity() { - private lateinit var repository: PlatoonRepository private lateinit var profileBinding: ActivePlatoonScopeBinding - private lateinit var summary: TextView - private lateinit var memberContainer: LinearLayout + private lateinit var repository: PlatoonRepository + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2Platoon") + private val memberAdapter = MemberAdapter() + private val stateAdapter = SingleViewAdapter() + private lateinit var summary: LinearLayout private lateinit var searchInput: EditText - private lateinit var filterSpinner: Spinner - private lateinit var sortSpinner: Spinner - private var statuses = emptyList() - private var latestMembers = emptyMap() - private val selectedUids = linkedSetOf() - private var pendingMemberCsv: String? = null - private val reconciliationExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2PlatoonReconciliation") + private lateinit var sortButton: TextView + private lateinit var resultCount: TextView + + private var model: Model? = null + private var filter = Filter.ACTIVE + private var sort = Sort.NAME + private var selecting = false + private val selected = linkedSetOf() + private var pendingExport: LongArray? = null + + private val exportMembers = registerForActivityResult( + ActivityResultContracts.CreateDocument("text/csv"), + ) { uri -> + val uids = pendingExport?.toSet() + pendingExport = null + if (uri == null || uids.isNullOrEmpty()) return@registerForActivityResult + work.run( + task = { + val statuses = repository.listMemberStatuses().filter { it.uid in uids } + val latest = latestMembers(repository.listSnapshots(1).firstOrNull()) + val csv = PlatoonMemberCsv.format(statuses, latest, repository.gameZone()) + DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } + }, + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.members_exported else R.string.status_export_failed) + if (result.isSuccess) setSelecting(false) + } + } + + private val exitSelection = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = setSelecting(false) } - private var reconciliationGeneration = 0 - private var screenResumed = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) val scope = profileBinding.scope ?: run { - TransientMessage.show(this, R.string.no_platoon_detected_detail, Toast.LENGTH_LONG) + Feedback.show(this, R.string.no_platoon_detected_detail, long = true) finish() return } repository = PlatoonRepository(this, scope) - setContentView( - PrimaryNavigation.wrap( - this, - buildContentView(), - PrimaryNavigation.Destination.PLATOON, - ), - ) + savedInstanceState?.let { state -> + filter = Filter.entries.getOrElse(state.getInt(STATE_FILTER)) { Filter.ACTIVE } + sort = Sort.entries.getOrElse(state.getInt(STATE_SORT)) { Sort.NAME } + selecting = state.getBoolean(STATE_SELECTING) + state.getLongArray(STATE_SELECTED)?.let { selected += it.toList() } + pendingExport = state.getLongArray(STATE_PENDING_EXPORT) + } + onBackPressedDispatcher.addCallback(this, exitSelection) + screen = Screen(this, PrimaryNavigation.Destination.PLATOON) + val header = buildHeader(savedInstanceState?.getString(STATE_QUERY).orEmpty()) + screen.setBody(RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(SingleViewAdapter(header), memberAdapter, stateAdapter) + itemAnimator = null + setPadding(dp(16), 0, dp(16), dp(24)) + }) + setSelecting(selecting) } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { recreate() return } - screenResumed = true - val generation = ++reconciliationGeneration - reconciliationExecutor.execute { - val reconciliation = runCatching { repository.reconcileRetainedCsvFiles() } - reconciliation.exceptionOrNull()?.let { error -> - Log.e(TAG, "Retained Platoon CSV reconciliation failed", error) - } - runOnUiThread { - if (generation == reconciliationGeneration && screenResumed && - !isFinishing && !isDestroyed - ) { - if (reconciliation.isSuccess) { - refresh() - } else { - summary.setText(R.string.status_platoon_csv_import_failed) - } - } - } - } - } - - override fun onPause() { - screenResumed = false - reconciliationGeneration += 1 - super.onPause() + load() } - override fun onDestroy() { - reconciliationExecutor.shutdownNow() - super.onDestroy() + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_FILTER, filter.ordinal) + outState.putInt(STATE_SORT, sort.ordinal) + outState.putBoolean(STATE_SELECTING, selecting) + outState.putLongArray(STATE_SELECTED, selected.toLongArray()) + if (::searchInput.isInitialized) outState.putString(STATE_QUERY, searchInput.text.toString()) + pendingExport?.let { outState.putLongArray(STATE_PENDING_EXPORT, it) } + super.onSaveInstanceState(outState) } - private fun buildContentView(): ScrollView { - val spacing = dp(16) - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, spacing, spacing, spacing) - addView(TextView(context).apply { - text = getString(R.string.platoon_management) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView( - PlatoonProfileSelector.controls(this@PlatoonActivity), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - topMargin = dp(8) - bottomMargin = dp(4) + private fun load() { + if (model == null) stateAdapter.view = StateViews.loading(this, getString(R.string.loading_members)) + work.run( + key = LOAD, + task = { + repository.reconcileRetainedCsvFiles() + val latest = repository.listSnapshots(1).firstOrNull() + Model( + latest = latest, + statuses = repository.listMemberStatuses(), + latestMembers = latestMembers(latest), + zone = repository.gameZone(), + ) + }, + ) { result -> + result.fold( + onSuccess = { loaded -> + model = loaded + selected.retainAll(loaded.statuses.mapTo(mutableSetOf(), MemberStatus::uid)) + if (selecting) setSelecting(true) + renderSummary(loaded) + renderMembers() + }, + onFailure = { + memberAdapter.submitList(emptyList()) + stateAdapter.view = StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() } }, ) - summary = TextView(context).apply { - textSize = 14f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(6), 0, dp(8)) - } - addView(summary, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.weekly_table) - useFeatureActionStyle() - setOnClickListener { - startActivity(Intent(this@PlatoonActivity, WeeklyReportActivity::class.java)) - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.compare_latest_snapshots) - useNavigationActionStyle() - setOnClickListener { - startActivity( - Intent(this@PlatoonActivity, SnapshotComparisonActivity::class.java), - ) - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.export_selected_members) - useNavigationActionStyle() - setOnClickListener { exportSelectedMembers() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.add_withdrawn_member) - useNavigationActionStyle() - setOnClickListener { showAddWithdrawnMemberDialog() } - }, matchWidth()) - searchInput = EditText(context).apply { - hint = getString(R.string.search_members) - setSingleLine(true) - addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - renderMembers() - } - override fun afterTextChanged(s: Editable?) = Unit - }) - } - addView(searchInput, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - topMargin = dp(8) - bottomMargin = dp(8) - }) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - filterSpinner = spinner( - listOf( - getString(R.string.filter_active), - getString(R.string.filter_all), - ), - ) - sortSpinner = spinner( - listOf( - getString(R.string.sort_name), - getString(R.string.sort_merit), - getString(R.string.sort_total_merit_management), - getString(R.string.sort_last_login), - ), - ) - addView(filterSpinner, LinearLayout.LayoutParams(0, wrap(), 1f).apply { - marginEnd = dp(4) - }) - addView(sortSpinner, LinearLayout.LayoutParams(0, wrap(), 1f).apply { - marginStart = dp(4) - }) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(8) }) - filterSpinner.onItemSelectedListener = SimpleItemSelectedListener { renderMembers() } - sortSpinner.onItemSelectedListener = SimpleItemSelectedListener { renderMembers() } - memberContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - } - addView(memberContainer, matchWidth()) } - return ScrollView(this).apply { addView(container, matchWidth()) } } - private fun refresh() { - val snapshots = repository.listSnapshots(1) - val latest = snapshots.firstOrNull() - statuses = repository.listMemberStatuses() - selectedUids.retainAll(statuses.mapTo(mutableSetOf(), MemberStatus::uid)) - latestMembers = latest?.members.orEmpty().associateBy(SnapshotMember::uid) - val active = statuses.count(MemberStatus::isActive) - val departed = statuses.size - active - summary.text = if (latest == null) { - getString(R.string.no_platoon_snapshot) - } else { - val zone = GameTimeZonePreferences.get(this@PlatoonActivity) - getString( - R.string.platoon_summary, - active, - departed, - DISPLAY_TIME.format(latest.capturedAt.atZone(zone)), - zone.id, - ) + private fun buildHeader(query: String): View { + val search = searchField(getString(R.string.search_members), R.id.platoon_search_input, query) { renderMembers() } + searchInput = search.findViewById(R.id.platoon_search_input) + sortButton = text(null, TextRole.LABEL) { + setTextColor(getColor(R.color.text_primary)) + gravity = Gravity.CENTER_VERTICAL + minHeight = dp(48) + setPadding(dp(8), 0, dp(8), 0) + background = selectableRect() + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_sort, 0, 0, 0) + compoundDrawablePadding = dp(6) + compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.text_secondary)) + setOnClickListener { chooseSort() } + } + resultCount = text(null, TextRole.LABEL) { + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } + summary = row(Gravity.TOP) + updateSortLabel() + return column { + addView(PlatoonProfileSelector.card(this@PlatoonActivity), lp(top = 4)) + addView(summary, lp(top = 12)) + addView(search, lp(top = 16)) + addView(Controls.segmentedControl( + context = this@PlatoonActivity, + options = Filter.entries.map { it.name to getString(it.label) }, + selectedValue = filter.name, + ) { value -> + filter = Filter.valueOf(value) + renderMembers() + }, lp(top = 8)) + addView(row { + addView(resultCount, lp(width = 0, weight = 1f, start = 4)) + addView(sortButton, lp(WRAP, WRAP)) + }, lp(top = 4, bottom = 4)) + } + } + + private fun renderSummary(loaded: Model) { + summary.removeAllViews() + val active = loaded.statuses.count(MemberStatus::isActive) + val tiles = listOf( + getString(R.string.stat_active) to active.toString(), + getString(R.string.stat_withdrawn) to (loaded.statuses.size - active).toString(), + getString(R.string.stat_last_snapshot) to (loaded.latest?.let { + Labels.format(Labels.DATE_TIME, it.capturedAt, ZoneId.systemDefault()) + } ?: getString(R.string.none)), + ) + tiles.forEachIndexed { index, (label, value) -> + summary.addView(panel(padding = 12) { + isFocusable = true + contentDescription = Labels.labeled(context, label, value) + addView(text(label, TextRole.CAPTION) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, lp()) + addView(text(value, if (index < 2) TextRole.HEADLINE else TextRole.BODY) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + maxLines = 2 + }, lp(top = 2)) + }, lp(width = 0, height = MATCH, weight = if (index < 2) 1f else 1.6f, start = if (index == 0) 0 else 8)) } - renderMembers() } private fun renderMembers() { - if (!::memberContainer.isInitialized) return + val loaded = model ?: return val query = searchInput.text.toString().trim() - val filtered = statuses.filter { status -> - val matchesFilter = when (filterSpinner.selectedItemPosition) { - 0 -> status.isActive - else -> true + val visible = loaded.statuses + .filter { status -> + when (filter) { + Filter.ACTIVE -> status.isActive + Filter.WITHDRAWN -> !status.isActive + Filter.ALL -> true + } } - val matchesQuery = query.isBlank() || - status.name.contains(query, ignoreCase = true) || - status.uid.toString().contains(query) - matchesFilter && matchesQuery - } - val sorted = when (sortSpinner.selectedItemPosition) { - 1 -> filtered.sortedByDescending { latestMembers[it.uid]?.weeklyMerit ?: -1 } - 2 -> filtered.sortedByDescending { latestMembers[it.uid]?.totalMerit ?: -1 } - 3 -> filtered.sortedByDescending { latestMembers[it.uid]?.lastLogin ?: 0 } - else -> filtered.sortedWith( - compareByDescending(MemberStatus::isActive) - .thenBy(String.CASE_INSENSITIVE_ORDER, MemberStatus::name), + .filter { query.isEmpty() || it.name.contains(query, ignoreCase = true) || it.uid.toString().contains(query) } + .let { sorted(it, loaded) } + resultCount.text = resources.getQuantityString(R.plurals.member_count, visible.size, visible.size) + stateAdapter.view = when { + visible.isNotEmpty() -> null + loaded.statuses.isEmpty() -> StateViews.empty( + this, + R.drawable.ic_group, + getString(R.string.no_platoon_snapshot), + getString(R.string.no_platoon_snapshot_detail), ) + else -> StateViews.empty(this, R.drawable.ic_search, getString(R.string.no_matching_members)) } + memberAdapter.submitList(visible) + } - memberContainer.removeAllViews() - if (sorted.isEmpty()) { - memberContainer.addView(TextView(this).apply { - text = getString(R.string.no_matching_members) - setPadding(0, dp(16), 0, dp(16)) - }, matchWidth()) - return + private fun sorted(statuses: List, loaded: Model): List = when (sort) { + Sort.NAME -> statuses.sortedWith( + compareByDescending(MemberStatus::isActive) + .thenBy(String.CASE_INSENSITIVE_ORDER, MemberStatus::name), + ) + Sort.WEEKLY_MERIT -> statuses.sortedByDescending { loaded.latestMembers[it.uid]?.weeklyMerit ?: -1 } + Sort.TOTAL_MERIT -> statuses.sortedByDescending { loaded.latestMembers[it.uid]?.totalMerit ?: -1 } + Sort.LAST_LOGIN -> statuses.sortedByDescending { loaded.latestMembers[it.uid]?.lastLogin ?: 0 } + } + + private fun chooseSort() { + Dialogs.choose(this, getString(R.string.sort_by), Sort.entries.map { getString(it.label) }, sort.ordinal) { + sort = Sort.entries[it] + updateSortLabel() + renderMembers() } - sorted.forEach { status -> - val latest = latestMembers[status.uid] - memberContainer.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(CheckBox(context).apply { - isChecked = status.uid in selectedUids - contentDescription = getString(R.string.select_member, status.name) - setOnCheckedChangeListener { _, checked -> - if (checked) selectedUids += status.uid else selectedUids -= status.uid - } - }, LinearLayout.LayoutParams(dp(48), ViewGroup.LayoutParams.MATCH_PARENT).apply { - marginEnd = dp(8) - }) - addView(ModernUi.actionRow( - context = context, - title = buildString { - append(status.name) - append(" · ") - append( - getString( - if (status.isActive) R.string.active_member else R.string.departed_member, - ), - ) - }, - detail = buildString { - append("#") - append(status.uid) - if (latest != null) { - append(" · ") - append(getString(R.string.merit_this_week)) - append(" ") - append(latest.weeklyMerit) - append(" · ") - append(getString(R.string.total_merit)) - append(" ") - append(latest.totalMerit) - } - }, - titleMaxLines = 2, - onClick = { - startActivity( - Intent(this@PlatoonActivity, MemberDetailActivity::class.java) - .putExtra(MemberDetailActivity.EXTRA_UID, status.uid), - ) - }, - ), LinearLayout.LayoutParams(0, wrap(), 1f)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(8) }) + } + + private fun updateSortLabel() { + sortButton.text = getString(sort.label) + sortButton.contentDescription = Labels.labeled(this, getString(R.string.sort_by), getString(sort.label)) + } + + private fun setSelecting(enabled: Boolean) { + selecting = enabled + if (!enabled) selected.clear() + exitSelection.isEnabled = enabled + val bar = screen.topBar + if (enabled) { + bar.setNavigationIcon(R.drawable.ic_close, getString(R.string.cancel_selection)) + .setNavigation { setSelecting(false) } + .setTitle(resources.getQuantityString(R.plurals.selected_count, selected.size, selected.size)) + .setActions( + listOf( + BarAction(R.drawable.ic_check_circle, getString(R.string.select_all)) { selectAllVisible() }, + BarAction(R.drawable.ic_download, getString(R.string.export_selected_members), selected.isNotEmpty()) { + exportSelected() + }, + ), + ) + } else { + bar.setNavigation(null) + .setTitle(getString(R.string.platoon_management)) + .setActions( + listOf(BarAction(R.drawable.ic_compare, getString(R.string.compare_latest_snapshots)) { + startActivity(Intent(this, SnapshotComparisonActivity::class.java)) + }), + overflow = listOf( + MenuEntry(getString(R.string.select_members)) { setSelecting(true) }, + MenuEntry(getString(R.string.add_withdrawn_member)) { + WithdrawnMemberDialog.show(this, work, repository, { uid -> + model?.statuses?.any { it.uid == uid } == true + }, ::load) + }, + MenuEntry(getString(R.string.edit_member_order)) { + startActivity(Intent(this, MemberOrderActivity::class.java)) + }, + ), + ) } + memberAdapter.notifyItemRangeChanged(0, memberAdapter.itemCount) } - @Deprecated("Uses the platform document picker without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode != REQUEST_EXPORT_MEMBERS) return - val content = pendingMemberCsv - pendingMemberCsv = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || content == null) return - val exported = runCatching { - val output = TrustedExportDestination.openOutputStream(contentResolver, destination) - ?: error("Document provider did not open an output stream") - output.writer(Charsets.UTF_8).use { it.write(content) } - }.isSuccess - TransientMessage.show( - this, - getString(if (exported) R.string.members_exported else R.string.status_export_failed), - Toast.LENGTH_SHORT, - ) + private fun toggle(uid: Long) { + if (!selected.add(uid)) selected.remove(uid) + setSelecting(true) + } + + private fun selectAllVisible() { + val visible = memberAdapter.currentList.map(MemberStatus::uid) + if (selected.containsAll(visible)) selected.removeAll(visible.toSet()) else selected += visible + setSelecting(true) } - @Suppress("DEPRECATION") - private fun exportSelectedMembers() { - val selected = statuses.filter { it.uid in selectedUids } + private fun exportSelected() { if (selected.isEmpty()) { - TransientMessage.show(this, R.string.select_members_first) + Feedback.show(this, R.string.select_members_first) return } - pendingMemberCsv = PlatoonMemberCsv.format( - statuses = selected, - latestMembers = latestMembers, - zoneId = GameTimeZonePreferences.get(this), - ) - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_TITLE, "GF2logger-members.csv") - startActivityForResult(intent, REQUEST_EXPORT_MEMBERS) + pendingExport = selected.toLongArray() + exportMembers.launch("GF2logger-members-${Labels.FILE_STAMP.format(LocalDateTime.now())}.csv") } - private fun showAddWithdrawnMemberDialog() { - val uidInput = EditText(this).apply { - hint = getString(R.string.uid) - inputType = InputType.TYPE_CLASS_NUMBER - setSingleLine(true) - } - val nicknameInput = EditText(this).apply { - hint = getString(R.string.member_nickname) - setSingleLine(true) - } - val joined = DateTimePickerInput( - this, - getString(R.string.join_field), - dateRequired = true, - zone = GameTimeZonePreferences.get(this), - ) - val withdrew = DateTimePickerInput( - this, - getString(R.string.withdraw_field), - dateRequired = true, - zone = GameTimeZonePreferences.get(this), - ) - val noteInput = EditText(this).apply { - hint = getString(R.string.membership_note_hint) - minLines = 2 - } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), 0) - addView(uidInput, matchWidth()) - addView(nicknameInput, matchWidth()) - addView(joined, matchWidth()) - addView(withdrew, matchWidth()) - addView(noteInput, matchWidth()) - } - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.add_withdrawn_member) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.add, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val uid = uidInput.text.toString().trim().toLongOrNull() - val nickname = nicknameInput.text.toString().trim() - val joinedBoundary = joined.boundary - val withdrewBoundary = withdrew.boundary - val validRange = joinedBoundary != null && - withdrewBoundary != null && - isValidMembershipRange(joinedBoundary, withdrewBoundary) - val saved = uid != null && uid > 0 && nickname.isNotBlank() && validRange && - runCatching { - repository.addWithdrawnMember( - uid, - nickname, - requireNotNull(joinedBoundary), - requireNotNull(withdrewBoundary), - noteInput.text.toString(), + private fun latestMembers(snapshot: PlatoonSnapshot?): Map = + snapshot?.members.orEmpty().associateBy(SnapshotMember::uid) + + private inner class MemberAdapter : ListAdapter(DIFF) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MemberHolder(PlatoonMemberRow(this@PlatoonActivity)) + + override fun onBindViewHolder(holder: MemberHolder, position: Int) { + val member = getItem(position) + val loaded = model ?: return + holder.row.bind( + member = member, + latest = loaded.latestMembers[member.uid], + zone = loaded.zone, + selecting = selecting, + selected = member.uid in selected, + onClick = { + if (selecting) { + toggle(member.uid) + } else { + startActivity( + Intent(this@PlatoonActivity, MemberDetailActivity::class.java) + .putExtra(MemberDetailActivity.EXTRA_UID, member.uid), ) - }.getOrDefault(false) - if (saved) { - dialog.dismiss() - refresh() - TransientMessage.show(this, R.string.saved) - } else { - TransientMessage.show( - this, - R.string.invalid_withdrawn_member, - Toast.LENGTH_SHORT, - ) - } - } + } + }, + onLongClick = { toggle(member.uid) }, + ) } - dialog.show() } - private fun spinner(items: List) = Spinner(this).apply { - adapter = ArrayAdapter( - this@PlatoonActivity, - android.R.layout.simple_spinner_dropdown_item, - items, - ) - } + private class MemberHolder(val row: PlatoonMemberRow) : RecyclerView.ViewHolder(row.view) + - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, + private data class Model( + val latest: PlatoonSnapshot?, + val statuses: List, + val latestMembers: Map, + val zone: ZoneId, ) - companion object { - private const val TAG = "GF2Platoon" - private val DISPLAY_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") - private const val REQUEST_EXPORT_MEMBERS = 301 + private enum class Filter(val label: Int) { + ACTIVE(R.string.filter_active), + WITHDRAWN(R.string.filter_withdrawn), + ALL(R.string.filter_everyone), + } + + private enum class Sort(val label: Int) { + NAME(R.string.sort_name), + WEEKLY_MERIT(R.string.sort_merit), + TOTAL_MERIT(R.string.sort_total_merit_management), + LAST_LOGIN(R.string.sort_last_login), + } + + private companion object { + const val LOAD = "load" + const val STATE_FILTER = "platoon.filter" + const val STATE_SORT = "platoon.sort" + const val STATE_SELECTING = "platoon.selecting" + const val STATE_SELECTED = "platoon.selected" + const val STATE_QUERY = "platoon.query" + const val STATE_PENDING_EXPORT = "platoon.pending_export" + + val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MemberStatus, newItem: MemberStatus) = oldItem.uid == newItem.uid + override fun areContentsTheSame(oldItem: MemberStatus, newItem: MemberStatus) = oldItem == newItem + } } } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt index e6a898e..59cf2ae 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -2,312 +2,197 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog -import android.graphics.Typeface -import android.text.Editable -import android.text.InputFilter import android.text.InputType -import android.text.TextWatcher import android.view.Gravity -import android.view.ViewGroup -import android.widget.EditText -import android.widget.ImageButton +import android.view.View import android.widget.LinearLayout import android.widget.RadioButton import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.TextView import dev.gf2log.app.management.PlatoonClient import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.onTextChanged +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.util.concurrent.Executors /** Selects or declares the explicit profile that will own an identity-free roster CSV. */ internal object PlatoonCsvImportPrompt { fun show(activity: Activity, onSelected: (PlatoonProfile) -> Unit) { - showSelector( - activity = activity, - initiallySelectedStorageId = PlatoonProfileRegistry(activity).active()?.storageId, - onSelected = onSelected, - ) + showSelector(activity, PlatoonProfileRegistry(activity).active()?.storageId, onSelected) } - private fun showSelector( - activity: Activity, - initiallySelectedStorageId: String?, - onSelected: (PlatoonProfile) -> Unit, - ) { - val registry = PlatoonProfileRegistry(activity) - val profiles = registry.list() - var selected = profiles.firstOrNull { it.storageId == initiallySelectedStorageId } - val choices = RadioGroup(activity).apply { - orientation = RadioGroup.VERTICAL - profiles.forEach { profile -> - addView(RadioButton(context).apply { - id = android.view.View.generateViewId() - text = profileLabel(activity, profile) - textSize = 14f - minimumHeight = activity.dp(48) - isChecked = profile.storageId == selected?.storageId - setOnCheckedChangeListener { _, checked -> - if (checked) selected = profile - } - }, matchWidth()) - } + private fun showSelector(activity: Activity, preselected: String?, onSelected: (PlatoonProfile) -> Unit) { + val profiles = PlatoonProfileRegistry(activity).list() + var selected = profiles.firstOrNull { it.storageId == preselected } + lateinit var dialog: AlertDialog + val choices = radioGroup(activity, profiles.map { "${it.platoonName}\n${Labels.profileIdentity(activity, it)}" }, profiles.indexOf(selected)) { + selected = profiles[it] + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true } - val add = ImageButton(activity).apply { - setImageResource(R.drawable.ic_add_circle) - contentDescription = activity.getString(R.string.add_new_platoon_profile) - useModernIconStyle() - imageTintList = null - setPadding(activity.dp(10), activity.dp(10), activity.dp(10), activity.dp(10)) - } - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(activity.dp(16), activity.dp(4), activity.dp(16), 0) - addView(choices, matchWidth()) - addView( - add, - LinearLayout.LayoutParams(activity.dp(48), activity.dp(48)).apply { - gravity = Gravity.CENTER_HORIZONTAL - topMargin = activity.dp(if (profiles.isEmpty()) 4 else 8) - }, - ) + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.csv_import_choose_destination), TextRole.SECONDARY), activity.lp()) + if (profiles.isEmpty()) { + addView(activity.text(activity.getString(R.string.no_platoon_detected_detail), TextRole.BODY), activity.lp(top = 12)) + } else { + addView(choices, activity.lp(top = 8)) + } + // Start-aligned and content-sized so the plus icon sits beside its label like a choice row. + addView(activity.button(activity.getString(R.string.add_new_platoon_profile), Controls.Role.TERTIARY, R.drawable.ic_add) { + showCreate(activity) { created -> + dialog.dismiss() + showSelector(activity, created.storageId, onSelected) + } + }.apply { gravity = Gravity.START or Gravity.CENTER_VERTICAL }, activity.lp(WRAP, WRAP, top = 8)) } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.select_platoon) - .setView(ScrollView(activity).apply { addView(content) }) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.select, null) - .create() - dialog.setOnShowListener { - val select = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.csv_import_destination_title) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.continue_action, null), + ) { shown -> + shown.getButton(AlertDialog.BUTTON_POSITIVE).apply { isEnabled = selected != null - usePrimaryActionStyle() setOnClickListener { val target = selected ?: return@setOnClickListener - dialog.dismiss() + shown.dismiss() onSelected(target) } } - choices.setOnCheckedChangeListener { _, checkedId -> - val index = (0 until choices.childCount) - .indexOfFirst { choices.getChildAt(it).id == checkedId } - selected = profiles.getOrNull(index) - select.isEnabled = selected != null - } - add.setOnClickListener { - showCreate(activity, registry) { created -> - dialog.dismiss() - showSelector(activity, created.storageId, onSelected) - } - } } - dialog.show() } - private fun showCreate( - activity: Activity, - registry: PlatoonProfileRegistry, - onCreated: (PlatoonProfile) -> Unit, - ) { - var client = PlatoonClient.HAOPLAY + private fun showCreate(activity: Activity, onCreated: (PlatoonProfile) -> Unit) { + val clients = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) + var client = clients.first() var region: GameServerRegion? = null - val name = EditText(activity).apply { - hint = activity.getString(R.string.platoon_name) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - filters = arrayOf(InputFilter.LengthFilter(PlatoonProfile.MAX_NAME_LENGTH)) - maxLines = 1 - setSingleLine(true) - } - val platoonIdInput = EditText(activity).apply { - hint = activity.getString(R.string.platoon_id) - inputType = InputType.TYPE_CLASS_NUMBER - maxLines = 1 - setSingleLine(true) - } - val serverChoices = RadioGroup(activity).apply { - orientation = RadioGroup.VERTICAL + val name = LabeledField( + activity, + activity.getString(R.string.platoon_name), + activity.textField(activity.getString(R.string.platoon_name), maxLength = PlatoonProfile.MAX_NAME_LENGTH), + ) + val platoonId = LabeledField( + activity, + activity.getString(R.string.platoon_id), + activity.textField(activity.getString(R.string.platoon_id_hint), inputType = InputType.TYPE_CLASS_NUMBER), + ) + val servers = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } + val error = activity.errorLine() + lateinit var dialog: AlertDialog + fun validId(): Long? = platoonId.input.text.toString().toLongOrNull()?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } + fun updateEnabled() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + name.input.text.isNotBlank() && validId() != null && region != null } - fun rebuildServerChoices() { + fun rebuildServers() { region = null - serverChoices.removeAllViews() - ClientServerRegionPreferences.allowedFor(client.packageName).forEach { candidate -> - serverChoices.addView(RadioButton(activity).apply { - id = android.view.View.generateViewId() - text = serverRegionLabel(activity, candidate) - textSize = 14f - minimumHeight = activity.dp(48) - setOnCheckedChangeListener { _, checked -> - if (checked) region = candidate - } - }, matchWidth()) - } - } - rebuildServerChoices() - val clients = RadioGroup(activity).apply { - orientation = RadioGroup.HORIZONTAL - listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER).forEach { candidate -> - addView(RadioButton(context).apply { - id = android.view.View.generateViewId() - text = candidate.displayName - textSize = 14f - minimumHeight = activity.dp(48) - isChecked = candidate == client - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - } - setOnCheckedChangeListener { group, checkedId -> - val index = (0 until group.childCount) - .indexOfFirst { group.getChildAt(it).id == checkedId } - client = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) - .getOrElse(index) { PlatoonClient.HAOPLAY } - rebuildServerChoices() - } - } - val error = TextView(activity).apply { - textSize = 12f - setTextColor(context.getColor(R.color.destructive_action)) - visibility = android.view.View.GONE - } - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(activity.dp(20), activity.dp(4), activity.dp(20), 0) - addView(TextView(context).apply { - text = activity.getString(R.string.new_platoon_entry_message) - textSize = 14f - setTextColor(context.getColor(R.color.text_secondary)) - }, matchWidth()) - addView(fieldLabel(activity, R.string.platoon_client), matchWidth().apply { - topMargin = activity.dp(16) - }) - addView(clients, matchWidth()) - addView(fieldLabel(activity, R.string.server_region), matchWidth().apply { - topMargin = activity.dp(8) - }) - addView(serverChoices, matchWidth()) - addView(fieldLabel(activity, R.string.platoon_name), matchWidth().apply { - topMargin = activity.dp(8) - }) - addView(name, matchWidth()) - addView(fieldLabel(activity, R.string.platoon_id), matchWidth().apply { - topMargin = activity.dp(8) + servers.removeAllViews() + val allowed = ClientServerRegionPreferences.allowedFor(client.packageName) + servers.addView(radioGroup(activity, allowed.map { Labels.region(activity, it) }, -1) { + region = allowed[it] + updateEnabled() }) - addView(platoonIdInput, matchWidth()) - addView(error, matchWidth().apply { topMargin = activity.dp(8) }) } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.enter_new_platoon_profile) - .setView(ScrollView(activity).apply { addView(content) }) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.confirm, null) - .create() - dialog.setOnShowListener { - val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - usePrimaryActionStyle() - } - fun validId(): Long? = platoonIdInput.text.toString().toLongOrNull() - ?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } - fun updateEnabled() { - confirm.isEnabled = name.text.toString().trim().isNotEmpty() && - validId() != null && - region != null - } - val watcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - error.visibility = android.view.View.GONE - updateEnabled() - } - override fun afterTextChanged(s: Editable?) = Unit - } - name.addTextChangedListener(watcher) - platoonIdInput.addTextChangedListener(watcher) - clients.setOnCheckedChangeListener { group, checkedId -> - val index = (0 until group.childCount) - .indexOfFirst { group.getChildAt(it).id == checkedId } - client = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) - .getOrElse(index) { PlatoonClient.HAOPLAY } - rebuildServerChoices() - serverChoices.setOnCheckedChangeListener { _, checkedRegionId -> - val regions = ClientServerRegionPreferences.allowedFor(client.packageName) - val selectedIndex = (0 until serverChoices.childCount) - .indexOfFirst { serverChoices.getChildAt(it).id == checkedRegionId } - region = regions.getOrNull(selectedIndex) + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.new_platoon_entry_message), TextRole.SECONDARY), activity.lp()) + addView(activity.text(activity.getString(R.string.platoon_client), TextRole.LABEL), activity.lp(top = 12)) + addView(Controls.segmentedControl( + context = activity, + options = clients.map { it.name to it.displayName }, + selectedValue = client.name, + ) { value -> + client = PlatoonClient.valueOf(value) + rebuildServers() + updateEnabled() + }, activity.lp(top = 4)) + addView(activity.text(activity.getString(R.string.server_region), TextRole.LABEL), activity.lp(top = 12)) + addView(servers, activity.lp()) + addView(name, activity.lp(top = 12)) + addView(platoonId, activity.lp(top = 12)) + addView(error, activity.lp(top = 8)) + } + rebuildServers() + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.enter_new_platoon_profile) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { shown -> + listOf(name.input, platoonId.input).forEach { field -> + field.onTextChanged { + error.visibility = View.GONE updateEnabled() } - updateEnabled() } - serverChoices.setOnCheckedChangeListener { _, checkedId -> - val regions = ClientServerRegionPreferences.allowedFor(client.packageName) - val index = (0 until serverChoices.childCount) - .indexOfFirst { serverChoices.getChildAt(it).id == checkedId } - region = regions.getOrNull(index) - updateEnabled() - } - confirm.setOnClickListener { + shown.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val selectedRegion = region ?: return@setOnClickListener - val platoonId = validId() ?: return@setOnClickListener - runCatching { - registry.createDeclared( - client = client, - region = selectedRegion, - platoonId = platoonId, - platoonName = name.text.toString(), - ) - }.fold( - onSuccess = { - dialog.dismiss() - onCreated(it) - }, - onFailure = { - error.text = activity.getString(R.string.unable_to_add_platoon_profile) - error.visibility = android.view.View.VISIBLE - }, - ) + val id = validId() ?: return@setOnClickListener + val chosenClient = client + val platoonName = name.input.text.toString() + button.isEnabled = false + profileExecutor.execute { + val result = runCatching { + PlatoonProfileRegistry(activity).createDeclared(chosenClient, selectedRegion, id, platoonName) + } + activity.runOnUiThread { + if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread + button.isEnabled = true + result.fold( + onSuccess = { + shown.dismiss() + onCreated(it) + }, + onFailure = { + error.showMessage(activity.getString(R.string.unable_to_add_platoon_profile)) + }, + ) + } + } } updateEnabled() } - dialog.show() } - private fun fieldLabel(activity: Activity, text: Int) = TextView(activity).apply { - setText(text) - textSize = 13f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - setTextColor(context.getColor(R.color.text_secondary)) + private fun radioGroup( + activity: Activity, + labels: List, + checked: Int, + onChecked: (Int) -> Unit, + ): RadioGroup = RadioGroup(activity).apply { + orientation = RadioGroup.VERTICAL + val ids = labels.mapIndexed { index, label -> + RadioButton(activity).apply { + id = View.generateViewId() + text = label + textSize = 14f + setTextColor(activity.getColor(R.color.text_primary)) + buttonTintList = Controls.controlTint(activity) + minimumHeight = activity.dp(48) + setPadding(activity.dp(8), activity.dp(6), 0, activity.dp(6)) + isChecked = index == checked + }.also { addView(it, RadioGroup.LayoutParams(RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.WRAP_CONTENT)) }.id + } + setOnCheckedChangeListener { _, checkedId -> + ids.indexOf(checkedId).takeIf { it >= 0 }?.let(onChecked) + } } - private fun profileLabel(activity: Activity, profile: PlatoonProfile): String = - "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + - "${profile.platoonName} / ${profile.platoonId}" - - private fun regionCode(region: GameServerRegion): String = when (region) { - GameServerRegion.MANUAL -> "Manual" - GameServerRegion.DARKWINTER_GLOBAL, GameServerRegion.HAOPLAY_GLOBAL -> "GL" - GameServerRegion.DARKWINTER_CHINA -> "CN" - GameServerRegion.HAOPLAY_JAPAN -> "JP" - GameServerRegion.HAOPLAY_KOREA -> "KR" - GameServerRegion.HAOPLAY_ASIA -> "ASIA" + private val profileExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "GF2ProfileCreate") } - - private fun serverRegionLabel(activity: Activity, region: GameServerRegion): String = - activity.getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun matchWidth() = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private fun Activity.dp(value: Int): Int = - (value * resources.displayMetrics.density).toInt() } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt b/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt new file mode 100644 index 0000000..c0faf7a --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt @@ -0,0 +1,111 @@ +package dev.gf2log.app + +import android.content.Context +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.CheckBox +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.management.MemberStatus +import dev.gf2log.app.management.SnapshotMember +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import java.time.Instant +import java.time.ZoneId + +/** One recyclable roster row: name, status, level and last login, and this week's merit. */ +internal class PlatoonMemberRow(private val context: Context) { + val view = AccessibleRow(context) + private val check = CheckBox(context).apply { + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + buttonTintList = Controls.controlTint(context) + } + private val name = context.text(null, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val status = context.badge("") + private val detail = context.text(null, TextRole.CAPTION) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val merit = context.text(null, TextRole.SUBTITLE) { gravity = Gravity.END } + + init { + with(context) { + view.orientation = LinearLayout.HORIZONTAL + view.gravity = Gravity.CENTER_VERTICAL + view.minimumHeight = dp(64) + view.setPadding(dp(8), dp(8), dp(8), dp(8)) + view.background = selectableRect() + view.layoutParams = RecyclerView.LayoutParams(MATCH, WRAP).apply { bottomMargin = dp(2) } + view.addView(check, lp(WRAP, WRAP, end = 4)) + view.addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(row { + addView(name, lp(width = 0, weight = 1f)) + addView(status, lp(WRAP, WRAP, start = 8)) + }, lp()) + addView(detail, lp(top = 2)) + }, lp(width = 0, weight = 1f)) + view.addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(merit, lp()) + addView(text(getString(R.string.merit_this_week_short), TextRole.CAPTION) { gravity = Gravity.END }, lp()) + }, lp(WRAP, WRAP, start = 12)) + view.addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + } + } + + fun bind( + member: MemberStatus, + latest: SnapshotMember?, + zone: ZoneId, + selecting: Boolean, + selected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) = with(context) { + check.visibility = if (selecting) View.VISIBLE else View.GONE + check.isChecked = selected + name.text = member.name + status.text = getString(if (member.isActive) R.string.active_member else R.string.departed_member) + status.visibility = if (member.isActive) View.GONE else View.VISIBLE + detail.text = buildList { + add(Labels.uid(context, member.uid)) + add(getString(R.string.level_short, latest?.level ?: member.level)) + latest?.lastLogin?.takeIf { it > 0 }?.let { + add(getString(R.string.last_login_short, Labels.format(Labels.DATE_TIME, Instant.ofEpochSecond(it), zone))) + } + }.joinToString(" · ") + merit.text = latest?.weeklyMerit?.toString() ?: "—" + view.isSelected = selecting && selected + view.contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { view.isSelected }, + member.name, + status.text, + detail.text, + Labels.labeled(context, getString(R.string.merit_this_week), merit.text), + ).joinToString(". ") + view.setOnClickListener { onClick() } + view.setOnLongClickListener { + onLongClick() + true + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 01efed9..5c6e2c2 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -2,414 +2,325 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog -import android.content.res.ColorStateList -import android.graphics.Typeface -import android.text.Editable +import android.graphics.drawable.GradientDrawable import android.text.InputType import android.text.TextUtils -import android.text.TextWatcher import android.view.Gravity import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.TextView -import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonProfileAdministration import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.settings.ClientServerRegionPreferences -import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.onTextChanged +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.CaptureGuard +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.lang.ref.WeakReference import java.util.concurrent.Executors /** Shared, presentation-only selector and manager for isolated Platoon scopes. */ internal object PlatoonProfileSelector { - fun controls( - activity: Activity, - compact: Boolean = false, - showManageButton: Boolean = true, - ): LinearLayout = - LinearLayout(activity).apply { + /** + * The active-Platoon card: emblem, name, and verified identity. Tapping it opens the selector; + * a profile switch recreates the hosting screen so every repository rebinds to the new scope. + */ + fun card(activity: Activity, compact: Boolean = false): View { + val profile = PlatoonProfileRegistry(activity).active() + return AccessibleRow(activity).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - addView( - selectorButton(activity, compact), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - if (showManageButton) { - addView( - manageButton(activity), - LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { - marginStart = dp(activity, 8) - }, - ) - } - } - - private fun selectorButton(activity: Activity, compact: Boolean): Button { - val registry = PlatoonProfileRegistry(activity) - val activeProfile = registry.active() - return Button(activity).apply { - text = (activeProfile?.let { label(activity, it) } - ?: activity.getString(R.string.no_platoon_detected)) + " ▾" - contentDescription = activity.getString(R.string.select_platoon) - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - activeProfile?.let { profile -> - PlatoonBannerArtwork.drawable(context, profile)?.let { artwork -> - val iconSize = dp(activity, if (compact) 28 else 34) - artwork.setBounds(0, 0, iconSize, iconSize) - setCompoundDrawablesRelative(artwork, null, null, null) - compoundDrawablePadding = dp(activity, 8) - } - } - if (compact) { - textSize = 11f - maxWidth = dp(activity, 210) - setPadding(dp(activity, 10), 0, dp(activity, 10), 0) - useSelectorActionStyle() - } else { - useNavigationActionStyle() - } - setOnClickListener { showSelector(activity, registry) } + minimumHeight = activity.dp(if (compact) 56 else 64) + setPadding(activity.dp(12), activity.dp(8), activity.dp(8), activity.dp(8)) + background = outlinedSurface(activity) + contentDescription = activity.getString(R.string.select_platoon) + ". " + + (profile?.let { "${it.platoonName}. ${Labels.profileIdentity(activity, it)}" } + ?: activity.getString(R.string.no_platoon_detected)) + setOnClickListener { showSelector(activity) } + addView(emblem(activity, profile, if (compact) 36 else 44), activity.lp( + width = if (compact) 36 else 44, + height = if (compact) 36 else 44, + end = 12, + )) + addView(activity.column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(activity.text( + profile?.platoonName ?: activity.getString(R.string.no_platoon_detected), + if (compact) TextRole.SUBTITLE else TextRole.TITLE, + ) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text( + profile?.let { Labels.profileIdentity(activity, it) } + ?: activity.getString(R.string.no_platoon_detected_detail), + TextRole.CAPTION, + ) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + }, activity.lp(width = 0, weight = 1f)) + addView(activity.icon(R.drawable.ic_expand_more), activity.lp(24, 24, start = 8)) } } - private fun manageButton(activity: Activity) = ImageButton(activity).apply { - setImageResource(R.drawable.ic_edit) - contentDescription = activity.getString(R.string.manage_platoon_profiles) - useModernIconStyle() - setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)) - setOnClickListener { showManagement(activity, PlatoonProfileRegistry(activity)) } - } - - private fun label(activity: Activity, profile: PlatoonProfile): String = - "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + - "${profile.platoonName} / ${profile.platoonId}" - - private fun showSelector(activity: Activity, registry: PlatoonProfileRegistry) { + fun showSelector(activity: Activity) { + val registry = PlatoonProfileRegistry(activity) val profiles = registry.list() if (profiles.isEmpty()) { - showNoPlatoonMessage(activity) + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) return } val activeId = registry.active()?.storageId - lateinit var selectorDialog: AlertDialog - val choices = RadioGroup(activity).apply { - orientation = RadioGroup.VERTICAL - setPadding(dp(activity, 12), dp(activity, 4), dp(activity, 12), dp(activity, 4)) + lateinit var dialog: AlertDialog + val content = Dialogs.content(activity) { + setPadding(activity.dp(12), activity.dp(4), activity.dp(12), activity.dp(4)) profiles.forEach { profile -> - addView(RadioButton(context).apply { - text = label(activity, profile) - textSize = 14f - gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(activity, 56) - isChecked = profile.storageId == activeId - setPadding(dp(activity, 8), dp(activity, 4), dp(activity, 8), dp(activity, 4)) - PlatoonBannerArtwork.drawable(context, profile)?.let { artwork -> - val iconSize = dp(activity, 40) - artwork.setBounds(0, 0, iconSize, iconSize) - setCompoundDrawablesRelative(artwork, null, null, null) - compoundDrawablePadding = dp(activity, 10) - } - setOnClickListener { - selectorDialog.dismiss() - if (profile.storageId != activeId && registry.setActive(profile.storageId)) { - activity.recreate() - } + addView(profileRow(activity, profile, selected = profile.storageId == activeId) { + dialog.dismiss() + if (profile.storageId != activeId && registry.setActive(profile.storageId)) { + activity.recreate() } - }, RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) + }, activity.lp(top = 2, bottom = 2)) } } - selectorDialog = AlertDialog.Builder(activity) - .setTitle(R.string.select_platoon) - .setView(ScrollView(activity).apply { addView(choices) }) - .setNegativeButton(android.R.string.cancel, null) - .create() - selectorDialog.show() + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.select_platoon) + .setView(content) + .setNeutralButton(R.string.manage_platoon_profiles) { _, _ -> showManagement(activity) } + .setNegativeButton(R.string.cancel, null), + ) } - private fun showManagement(activity: Activity, registry: PlatoonProfileRegistry) { + fun showManagement(activity: Activity) { + val registry = PlatoonProfileRegistry(activity) val profiles = registry.list() if (profiles.isEmpty()) { - showNoPlatoonMessage(activity) + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) return } val activeId = registry.active()?.storageId - val list = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(activity, 12), dp(activity, 4), dp(activity, 12), dp(activity, 4)) + lateinit var dialog: AlertDialog + val content = Dialogs.content(activity) { + setPadding(activity.dp(12), 0, activity.dp(12), activity.dp(4)) + addView(activity.text(activity.getString(R.string.manage_platoon_profiles_detail), TextRole.SECONDARY) { + setPadding(activity.dp(12), 0, activity.dp(12), activity.dp(8)) + }, activity.lp()) profiles.forEachIndexed { index, profile -> - addView(profileManagementRow(activity, profile, profile.storageId == activeId)) - if (index != profiles.lastIndex) { - addView( - View(context).apply { - setBackgroundColor(context.getColor(R.color.outline)) - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(activity, 1), - ).apply { - marginStart = dp(activity, 12) - marginEnd = dp(activity, 12) - }, - ) - } + if (index > 0) addView(activity.divider(insetStartDp = 64)) + addView(managementRow(activity, profile, profile.storageId == activeId) { dialog.dismiss() }, activity.lp()) } } - AlertDialog.Builder(activity) - .setTitle(R.string.manage_platoon_profiles) - .setMessage(R.string.manage_platoon_profiles_detail) - .setView(ScrollView(activity).apply { addView(list) }) - .setNegativeButton(android.R.string.cancel, null) - .show() + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.manage_platoon_profiles) + .setView(content) + .setPositiveButton(R.string.done, null), + ) } - private fun profileManagementRow( + private fun profileRow( + activity: Activity, + profile: PlatoonProfile, + selected: Boolean, + onClick: () -> Unit, + ): View = AccessibleRow(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = activity.dp(64) + setPadding(activity.dp(12), activity.dp(8), activity.dp(12), activity.dp(8)) + background = if (selected) outlinedSurface(activity, accent = true) else activity.selectableRect() + isSelected = selected + contentDescription = "${profile.platoonName}. ${Labels.profileIdentity(activity, profile)}" + + if (selected) ". " + activity.getString(R.string.active_platoon) else "" + setOnClickListener { onClick() } + addView(emblem(activity, profile, 40), activity.lp(40, 40, end = 12)) + addView(activity.column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(activity.text(profile.platoonName, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text(Labels.profileIdentity(activity, profile), TextRole.CAPTION), activity.lp()) + }, activity.lp(width = 0, weight = 1f)) + if (selected) addView(activity.icon(R.drawable.ic_check, R.color.accent_text), activity.lp(24, 24, start = 8)) + } + + private fun managementRow( activity: Activity, profile: PlatoonProfile, active: Boolean, - ) = LinearLayout(activity).apply { + dismiss: () -> Unit, + ): View = LinearLayout(activity).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(activity, 64) - setPadding(dp(activity, 10), dp(activity, 8), dp(activity, 4), dp(activity, 8)) - PlatoonBannerArtwork.drawable(context, profile)?.let { artwork -> - addView(ImageView(context).apply { - setImageDrawable(artwork) - contentDescription = null - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(dp(activity, 44), dp(activity, 44)).apply { - marginEnd = dp(activity, 10) - }) - } - addView( - LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(TextView(context).apply { - text = profile.platoonName - textSize = 15f - typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - }, matchWidth()) - addView(TextView(context).apply { - text = activity.getString( - R.string.profile_management_identity, - profile.client.displayName, - regionCode(profile.serverRegion), - profile.platoonId, - ) + if (active) " · ${activity.getString(R.string.active_platoon)}" else "" - textSize = 12f - setTextColor(context.getColor(R.color.text_secondary)) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, matchWidth()) - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_edit) - contentDescription = activity.getString( - R.string.edit_platoon_server_description, - profile.platoonName, - ) - useModernIconStyle() - setOnClickListener { chooseProfileRegion(activity, profile) } - }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { - marginStart = dp(activity, 4) - }) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_delete) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.destructive_action)) - contentDescription = activity.getString( - R.string.delete_platoon_description, - profile.platoonName, - ) - background = ModernUi.panelBackground(context) - setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)) - setOnClickListener { confirmDeleteFirst(activity, profile) } - }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { - marginStart = dp(activity, 4) - }) + minimumHeight = activity.dp(64) + setPadding(activity.dp(12), activity.dp(8), 0, activity.dp(8)) + addView(emblem(activity, profile, 40), activity.lp(40, 40, end = 12)) + addView(activity.column { + addView(activity.text(profile.platoonName, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text( + Labels.profileIdentity(activity, profile) + + if (active) " · " + activity.getString(R.string.active_platoon) else "", + TextRole.CAPTION, + ), activity.lp()) + }, activity.lp(width = 0, weight = 1f)) + addView(activity.iconButton( + R.drawable.ic_edit, + activity.getString(R.string.edit_platoon_server_description, profile.platoonName), + ) { + dismiss() + chooseProfileRegion(activity, profile) + }, activity.lp(48, 48)) + addView(activity.iconButton( + R.drawable.ic_delete, + activity.getString(R.string.delete_platoon_description, profile.platoonName), + tint = R.color.destructive_action, + ) { + dismiss() + confirmDeleteFirst(activity, profile) + }, activity.lp(48, 48)) } - private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) { - if (CaptureStatus.isRunning) { - TransientMessage.show(activity, R.string.stop_capture_before_profile_change, android.widget.Toast.LENGTH_LONG) - return - } + private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) = + CaptureGuard.whenStopped(activity, R.string.stop_capture_before_profile_change) { chooseProfileRegionNow(activity, profile) } + + private fun chooseProfileRegionNow(activity: Activity, profile: PlatoonProfile) { val regions = ClientServerRegionPreferences.allowedFor(profile.client.packageName) - AlertDialog.Builder(activity) - .setTitle(R.string.edit_platoon_server) - .setMessage( - activity.getString( - R.string.edit_platoon_server_message, - profile.client.displayName, - profile.platoonName, - ), - ) - .setSingleChoiceItems( - regions.map { activity.serverRegionLabel(it) }.toTypedArray(), - regions.indexOf(profile.serverRegion), - ) { dialog, which -> - dialog.dismiss() - val selected = regions[which] - if (selected != profile.serverRegion) { - runMaintenance(activity, R.string.platoon_server_update_failed) { - PlatoonProfileAdministration(activity) - .changeServerRegion(profile.storageId, selected) + Dialogs.show( + Dialogs.builder(activity) + .setTitle(activity.getString(R.string.edit_platoon_server_title, profile.platoonName)) + .setSingleChoiceItems( + regions.map { Labels.region(activity, it) }.toTypedArray(), + regions.indexOf(profile.serverRegion), + ) { dialog, which -> + dialog.dismiss() + val selected = regions[which] + if (selected != profile.serverRegion) { + runMaintenance(activity, R.string.platoon_server_update_failed) { + PlatoonProfileAdministration(activity).changeServerRegion(profile.storageId, selected) + } } } - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setNegativeButton(R.string.cancel, null), + ) } - private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) { - if (CaptureStatus.isRunning) { - TransientMessage.show(activity, R.string.stop_capture_before_profile_change, android.widget.Toast.LENGTH_LONG) - return - } - val warning = AlertDialog.Builder(activity) - .setTitle(R.string.delete_platoon_profile) - .setMessage(activity.getString(R.string.delete_platoon_first_warning, profile.platoonName)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - warning.setOnShowListener { - warning.getButton(AlertDialog.BUTTON_POSITIVE).apply { - useDestructiveActionStyle() - setOnClickListener { - warning.dismiss() - confirmDeleteByName(activity, profile) - } - } - } - warning.show() + private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) = + CaptureGuard.whenStopped(activity, R.string.stop_capture_before_profile_change) { confirmDeleteFirstNow(activity, profile) } + + private fun confirmDeleteFirstNow(activity: Activity, profile: PlatoonProfile) { + Dialogs.confirm( + activity, + activity.getString(R.string.delete_platoon_profile), + activity.getString(R.string.delete_platoon_first_warning, profile.platoonName), + activity.getString(R.string.continue_action), + destructive = true, + ) { confirmDeleteByName(activity, profile) } } private fun confirmDeleteByName(activity: Activity, profile: PlatoonProfile) { - val input = EditText(activity).apply { - hint = profile.platoonName - inputType = InputType.TYPE_CLASS_TEXT - maxLines = 1 - setSingleLine(true) - contentDescription = activity.getString(R.string.type_platoon_name) + val input = activity.textField(activity.getString(R.string.type_platoon_name), inputType = InputType.TYPE_CLASS_TEXT) + val content = Dialogs.content(activity) { + addView(activity.text( + activity.getString(R.string.delete_platoon_name_warning, profile.platoonName), + TextRole.SECONDARY, + ), activity.lp()) + addView(input, activity.lp(top = 12)) } - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(activity, 20), dp(activity, 4), dp(activity, 20), 0) - addView(TextView(context).apply { - text = activity.getString(R.string.delete_platoon_name_warning, profile.platoonName) - textSize = 14f - setTextColor(context.getColor(R.color.text_secondary)) - }, matchWidth()) - addView(input, matchWidth().apply { topMargin = dp(activity, 12) }) - } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.delete_platoon_profile) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - dialog.setOnShowListener { - val delete = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - isEnabled = false - useDestructiveActionStyle() - setOnClickListener { - if (input.text.toString() != profile.platoonName) return@setOnClickListener - dialog.dismiss() - runMaintenance(activity, R.string.platoon_delete_failed) { - check(PlatoonProfileAdministration(activity).deleteProfile(profile.storageId)) { - "Unable to delete the Platoon profile" - } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.delete_platoon_profile) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete, null), + destructive = true, + ) { dialog -> + val delete = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + delete.isEnabled = false + delete.setOnClickListener { + if (input.text.toString() != profile.platoonName) return@setOnClickListener + dialog.dismiss() + runMaintenance(activity, R.string.platoon_delete_failed) { + check(PlatoonProfileAdministration(activity).deleteProfile(profile.storageId)) { + "Unable to delete the Platoon profile" } } } - input.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) { - delete.isEnabled = s?.toString() == profile.platoonName - } - - override fun afterTextChanged(s: Editable?) = Unit - }) + input.onTextChanged { delete.isEnabled = it == profile.platoonName } } - dialog.show() } - private fun runMaintenance( - activity: Activity, - failureMessage: Int, - operation: () -> Unit, - ) { + /** + * Profile maintenance outlives a rotation: the result only refreshes the screen that is still + * showing, and any later screen rebinds itself through [ActivePlatoonScopeBinding]. + */ + private fun runMaintenance(activity: Activity, failureMessage: Int, operation: () -> Unit) { + val screen = WeakReference(activity) + val appContext = activity.applicationContext maintenanceExecutor.execute { val result = runCatching(operation) - activity.runOnUiThread { - if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread - if (result.isSuccess) { - activity.recreate() - } else { - TransientMessage.show(activity, failureMessage, android.widget.Toast.LENGTH_LONG) - } + // A rotation may have replaced the caller; refresh whichever screen is showing now. + val current = screen.get()?.takeUnless { it.isFinishing || it.isDestroyed } + ?: BaseActivity.foregroundScreen() + if (current == null) { + if (result.isFailure) Feedback.show(appContext, failureMessage, long = true) + return@execute + } + current.runOnUiThread { + if (current.isFinishing || current.isDestroyed) return@runOnUiThread + if (result.isSuccess) current.recreate() else Feedback.show(current, failureMessage, long = true) } } } - private fun showNoPlatoonMessage(activity: Activity) { - TransientMessage.show(activity, R.string.no_platoon_detected_detail, android.widget.Toast.LENGTH_LONG) - } - - private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun regionCode(region: GameServerRegion): String = when (region) { - GameServerRegion.MANUAL -> "Manual" - GameServerRegion.DARKWINTER_GLOBAL -> "GL" - GameServerRegion.DARKWINTER_CHINA -> "CN" - GameServerRegion.HAOPLAY_GLOBAL -> "GL" - GameServerRegion.HAOPLAY_JAPAN -> "JP" - GameServerRegion.HAOPLAY_KOREA -> "KR" - GameServerRegion.HAOPLAY_ASIA -> "ASIA" - } - - private fun matchWidth() = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + fun emblem(activity: Activity, profile: PlatoonProfile?, sizeDp: Int): View = + ImageView(activity).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + val artwork = profile?.let { PlatoonBannerArtwork.drawable(activity, it) } + if (artwork != null) { + setImageDrawable(artwork) + } else { + setImageResource(R.drawable.ic_group) + imageTintList = android.content.res.ColorStateList.valueOf(activity.getColor(R.color.text_secondary)) + scaleType = ImageView.ScaleType.CENTER + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(activity.getColor(R.color.surface_variant)) + } + } + minimumWidth = activity.dp(sizeDp) + minimumHeight = activity.dp(sizeDp) + } - private fun dp(activity: Activity, value: Int): Int = - (value * activity.resources.displayMetrics.density).toInt() + private fun outlinedSurface(activity: Activity, accent: Boolean = false) = + android.graphics.drawable.RippleDrawable( + android.content.res.ColorStateList.valueOf(activity.getColor(R.color.control_ripple)), + GradientDrawable().apply { + setColor(activity.getColor(if (accent) R.color.accent_surface else R.color.surface)) + cornerRadius = activity.dpf(16f) + setStroke(activity.dp(1), activity.getColor(if (accent) R.color.accent else R.color.outline)) + }, + null, + ) private val maintenanceExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "GF2ProfileMaintenance") diff --git a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt b/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt deleted file mode 100644 index 8ce9b23..0000000 --- a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.gf2log.app - -import android.widget.Button - -fun Button.usePrimaryActionStyle() = useActionRole(ModernUi.ControlRole.PRIMARY) - -fun Button.useCaptureActionStyle() = useActionRole(ModernUi.ControlRole.CAPTURE) - -fun Button.useFeatureActionStyle() = useActionRole(ModernUi.ControlRole.FEATURE) - -fun Button.useSecondaryActionStyle() = useActionRole(ModernUi.ControlRole.SECONDARY) - -fun Button.useTertiaryActionStyle() = useActionRole(ModernUi.ControlRole.TERTIARY) - -fun Button.useNavigationActionStyle() = useActionRole(ModernUi.ControlRole.NAVIGATION) - -fun Button.useSelectorActionStyle() = useActionRole(ModernUi.ControlRole.SELECTOR) - -fun Button.useEvidenceActionStyle() = useActionRole(ModernUi.ControlRole.EVIDENCE) - -fun Button.useDestructiveActionStyle() = useActionRole(ModernUi.ControlRole.DESTRUCTIVE) - -fun Button.useDestructiveTextActionStyle() = useActionRole(ModernUi.ControlRole.DESTRUCTIVE_TEXT) - -fun Button.allowCompactMultilineLabel() { - setTag(R.id.gf2_ui_compact_multiline, true) - ModernUi.styleButton(this) -} - -private fun Button.useActionRole(role: ModernUi.ControlRole) { - setTag(R.id.gf2_ui_role, role) - ModernUi.styleButton(this) -} diff --git a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt index 3a034ff..d3fe07f 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt @@ -13,6 +13,8 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.dp import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -24,25 +26,12 @@ internal object PrimaryNavigation { WEEKLY, } - fun wrap(activity: Activity, content: View, selected: Destination): View = - LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(activity.getColor(R.color.app_background)) - addView(content, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 1f, - )) - addView(View(activity).apply { - setBackgroundColor(activity.getColor(R.color.outline)) - }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, activity.dp(1))) - addView(navigationBar(activity, selected), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - activity.dp(64), - )) - } + /** The bottom bar alone, for screens composed through [dev.gf2log.app.ui.Screen]. */ + fun bar(activity: Activity, selected: Destination): View = navigationBar(activity, selected).apply { + minimumHeight = activity.dp(64) + } - private fun navigationBar(activity: Activity, selected: Destination): View = + private fun navigationBar(activity: Activity, selected: Destination): LinearLayout = LinearLayout(activity).apply { orientation = LinearLayout.HORIZONTAL setBackgroundColor(activity.getColor(R.color.surface)) @@ -81,7 +70,7 @@ internal object PrimaryNavigation { activityClass: Class, ) { val active = destination == selected - val color = activity.getColor(if (active) R.color.accent else R.color.text_secondary) + val color = activity.getColor(if (active) R.color.accent_text else R.color.text_secondary) addView(LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER @@ -89,16 +78,25 @@ internal object PrimaryNavigation { isFocusable = true isSelected = active contentDescription = activity.getString(label) + androidx.core.view.ViewCompat.setStateDescription(this, if (active) activity.getString(R.string.selected) else null) background = activity.selectableBackground() setPadding(0, activity.dp(6), 0, activity.dp(4)) + // The selected destination sits on a soft accent pill, the rest stay neutral. addView(ImageView(activity).apply { setImageResource(icon) imageTintList = ColorStateList.valueOf(color) importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(activity.dp(24), activity.dp(24))) + scaleType = ImageView.ScaleType.CENTER + if (active) { + background = GradientDrawable().apply { + setColor(activity.getColor(R.color.accent_surface)) + cornerRadius = activity.dp(16).toFloat() + } + } + }, LinearLayout.LayoutParams(activity.dp(56), activity.dp(32))) addView(TextView(activity).apply { text = activity.getString(label) - textSize = 11f + textSize = 12f setTextColor(color) typeface = Typeface.create( "sans-serif-medium", @@ -114,11 +112,7 @@ internal object PrimaryNavigation { if (destination != Destination.HOME && PlatoonProfileRegistry(activity).active() == null ) { - TransientMessage.show( - activity, - R.string.no_platoon_detected_detail, - android.widget.Toast.LENGTH_LONG, - ) + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) } else { activity.startActivity( Intent(activity, activityClass).addFlags( diff --git a/app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt b/app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt deleted file mode 100644 index 48f9206..0000000 --- a/app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.gf2log.app - -import android.view.View -import android.widget.AdapterView - -class SimpleItemSelectedListener( - private val selected: () -> Unit, -) : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - selected() - } - - override fun onNothingSelected(parent: AdapterView<*>?) = Unit -} diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index 69451bc..8e990a7 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -1,109 +1,160 @@ package dev.gf2log.app -import android.content.ClipData -import android.content.ClipboardManager -import android.graphics.Typeface import android.os.Bundle -import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import android.text.TextUtils +import android.view.View +import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison import dev.gf2log.app.management.SnapshotComparisonCsv -import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.settings.GameTimeZonePreferences -import java.time.format.DateTimeFormatter +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.text +import java.time.ZoneId + +/** Joins, withdrawals, and counter changes between the two latest roster snapshots. */ +class SnapshotComparisonActivity : BaseActivity() { + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2SnapshotComparison") + private val content by lazy { column() } -class SnapshotComparisonActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val snapshots = PlatoonRepository(this).listSnapshots(2) - setContentView(ScrollView(this).apply { - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(heading(getString(R.string.snapshot_comparison), 24f)) - if (snapshots.size < 2) { - addView(text(getString(R.string.need_two_snapshots))) - } else { - val result = SnapshotComparison.compare(snapshots[1], snapshots[0]) - val zone = GameTimeZonePreferences.get(this@SnapshotComparisonActivity) - addView( - text( - getString( - R.string.comparison_period, - DISPLAY_TIME.format(result.older.capturedAt.atZone(zone)), - DISPLAY_TIME.format(result.newer.capturedAt.atZone(zone)), - ), - ), - ) - addView(Button(context).apply { - text = getString(R.string.copy_csv) - useTertiaryActionStyle() - setOnClickListener { - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText( - getString(R.string.snapshot_csv_clipboard_label), - SnapshotComparisonCsv.format(result), - ), - ) - TransientMessage.show( - this@SnapshotComparisonActivity, - R.string.snapshot_csv_copied, - Toast.LENGTH_SHORT, - ) - } - }) - addSection( - getString(R.string.joined_members), - result.joined.map { "${it.name} (#${it.uid})" }, - ) - addSection( - getString(R.string.departed_members), - result.left.map { "${it.name} (#${it.uid})" }, - ) - addSection( - getString(R.string.member_changes), - result.changes.map { - "${it.name} (#${it.uid}) " + - "${getString(R.string.merit_this_week)} +${it.weeklyMeritDelta}, " + - "${getString(R.string.total_merit)} +${it.totalMeritDelta}, " + - "${getString(R.string.gunsmoke_score)} +${it.totalScoreDelta}" - }, - ) - } - }, matchWidth()) - }) + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope ?: run { + finish() + return + } + screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle(getString(R.string.snapshot_comparison)) + screen.setBody(Screen.scrollingBody(this, content)) + content.addView(StateViews.loading(this, getString(R.string.loading_comparison))) + work.run( + task = { + val snapshots = PlatoonRepository(this, scope).listSnapshots(2) + snapshots.takeIf { it.size == 2 }?.let { SnapshotComparison.compare(it[1], it[0]) } + }, + ) { result -> + content.removeAllViews() + result.fold( + onSuccess = ::render, + onFailure = { content.addView(StateViews.failure(this, getString(R.string.platoon_load_failed)) { recreate() }) }, + ) + } } - private fun LinearLayout.addSection(title: String, lines: List) { - addView(heading(title, 20f)) - addView(text(lines.ifEmpty { listOf(getString(R.string.none)) }.joinToString("\n"))) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() } - private fun heading(value: String, size: Float) = TextView(this).apply { - text = value - textSize = size - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, dp(4)) + private fun render(result: SnapshotComparison.Result?) { + // Capture instants are shown in device time, like packet history. + val zone = ZoneId.systemDefault() + if (result == null) { + content.addView(StateViews.empty(this, R.drawable.ic_compare, getString(R.string.need_two_snapshots))) + return + } + screen.topBar.setActions(listOf(BarAction(R.drawable.ic_copy, getString(R.string.copy_csv)) { + Feedback.copy( + this, + getString(R.string.snapshot_csv_clipboard_label), + SnapshotComparisonCsv.format(result), + R.string.snapshot_csv_copied, + ) + })) + content.addView(panel { + addView(text(getString(R.string.comparison_window), TextRole.LABEL), lp()) + addView(text( + getString( + R.string.comparison_period, + Labels.format(Labels.DATE_TIME, result.older.capturedAt, zone), + Labels.format(Labels.DATE_TIME, result.newer.capturedAt, zone), + ), + TextRole.SUBTITLE, + ), lp(top = 2)) + addView(text( + getString(R.string.comparison_counts, result.joined.size, result.left.size, result.changes.size), + TextRole.CAPTION, + ), lp(top = 4)) + }, lp(top = 4)) + section(getString(R.string.joined_members), result.joined.size, result.joined.map { it.name to Labels.uid(this, it.uid) }) + section(getString(R.string.departed_members), result.left.size, result.left.map { it.name to Labels.uid(this, it.uid) }) + content.addView(sectionHeader(getString(R.string.member_changes_count, result.changes.size)), lp()) + if (result.changes.isEmpty()) { + content.addView(text(getString(R.string.none), TextRole.SECONDARY), lp(start = 4)) + return + } + content.addView(panel(padding = 8) { + result.changes.forEachIndexed { index, change -> + if (index > 0) addView(divider(insetStartDp = 8)) + addView(column { + setPadding(dp(8), dp(8), dp(8), dp(8)) + isFocusable = true + addView(row { + addView(text(change.name, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, lp(width = 0, weight = 1f)) + addView(text(Labels.uid(context, change.uid), TextRole.CAPTION), lp(WRAP, WRAP, start = 8)) + }, lp()) + addView(row { + listOf( + getString(R.string.merit_this_week_short) to change.weeklyMeritDelta, + getString(R.string.total_merit) to change.totalMeritDelta, + getString(R.string.gunsmoke_score) to change.totalScoreDelta, + ).forEachIndexed { badgeIndex, (label, delta) -> + addView(badge( + "$label +$delta", + if (delta > 0) R.color.accent_surface else R.color.surface_variant, + if (delta > 0) R.color.accent_text else R.color.text_secondary, + ), lp(WRAP, WRAP, start = if (badgeIndex == 0) 0 else 6)) + } + }, lp(top = 6)) + contentDescription = getString( + R.string.member_change_description, + change.name, + change.weeklyMeritDelta, + change.totalMeritDelta, + change.totalScoreDelta, + ) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + }, lp()) + } + }, lp()) } - private fun text(value: String) = TextView(this).apply { - text = value - textSize = 15f - setPadding(0, dp(4), 0, dp(8)) - setTextIsSelectable(true) + private fun section(title: String, count: Int, members: List>) { + content.addView(sectionHeader("$title · $count"), lp()) + if (members.isEmpty()) { + content.addView(text(getString(R.string.none), TextRole.SECONDARY), lp(start = 4)) + return + } + content.addView(panel(padding = 8) { + members.forEachIndexed { index, (name, uid) -> + if (index > 0) addView(divider(insetStartDp = 8)) + addView(row { + minimumHeight = dp(44) + setPadding(dp(8), 0, dp(8), 0) + addView(text(name, TextRole.BODY), lp(width = 0, weight = 1f)) + addView(text(uid, TextRole.CAPTION), lp(WRAP, WRAP)) + }, lp()) + } + }, lp()) } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - companion object { - private val DISPLAY_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") - } } diff --git a/app/src/main/java/dev/gf2log/app/TrustedExportDestination.java b/app/src/main/java/dev/gf2log/app/TrustedExportDestination.java deleted file mode 100644 index 2a14002..0000000 --- a/app/src/main/java/dev/gf2log/app/TrustedExportDestination.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.gf2log.app; - -import android.content.ContentResolver; -import android.net.Uri; - -import java.io.FileNotFoundException; -import java.io.OutputStream; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -final class TrustedExportDestination { - private static final Set TRUSTED_DOCUMENT_AUTHORITIES = new HashSet<>( - Arrays.asList( - "com.android.externalstorage.documents", - "com.android.providers.downloads.documents", - "com.android.providers.media.documents", - "com.google.android.apps.docs.storage", - "com.google.android.apps.nbu.files.provider" - ) - ); - - private TrustedExportDestination() { - } - - static OutputStream openOutputStream(ContentResolver resolver, Uri destination) - throws FileNotFoundException { - if (!ContentResolver.SCHEME_CONTENT.equals(destination.getScheme())) { - throw new SecurityException("Unsupported export URI scheme"); - } - - String authority = destination.getAuthority(); - if (authority == null || !TRUSTED_DOCUMENT_AUTHORITIES.contains(authority)) { - throw new SecurityException("Untrusted document provider"); - } - - String path = destination.getPath(); - if (path == null) { - throw new SecurityException("Export URI has no path"); - } - - Path normalizedPath = FileSystems.getDefault().getPath(path).normalize(); - if (normalizedPath.startsWith("/data")) { - throw new SecurityException("Export URI targets private app storage"); - } - - return resolver.openOutputStream(destination); - } -} diff --git a/app/src/main/java/dev/gf2log/app/TrustedImportSource.java b/app/src/main/java/dev/gf2log/app/TrustedImportSource.java deleted file mode 100644 index 22cf85f..0000000 --- a/app/src/main/java/dev/gf2log/app/TrustedImportSource.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.gf2log.app; - -import android.content.ContentResolver; -import android.net.Uri; - -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -final class TrustedImportSource { - private static final Set TRUSTED_DOCUMENT_AUTHORITIES = new HashSet<>( - Arrays.asList( - "com.android.externalstorage.documents", - "com.android.providers.downloads.documents", - "com.android.providers.media.documents", - "com.google.android.apps.docs.storage", - "com.google.android.apps.nbu.files.provider" - ) - ); - - private TrustedImportSource() { - } - - static InputStream openInputStream(ContentResolver resolver, Uri source) - throws FileNotFoundException { - if (!ContentResolver.SCHEME_CONTENT.equals(source.getScheme())) { - throw new SecurityException("Unsupported import URI scheme"); - } - String authority = source.getAuthority(); - if (authority == null || !TRUSTED_DOCUMENT_AUTHORITIES.contains(authority)) { - throw new SecurityException("Untrusted document provider"); - } - return resolver.openInputStream(source); - } -} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt new file mode 100644 index 0000000..89d7e96 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -0,0 +1,290 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.text.InputType +import android.view.Gravity +import android.view.View +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.management.WeeklyEvidenceAnalyzer +import dev.gf2log.app.management.WeeklyEvidenceAnalyzer.Metric +import dev.gf2log.app.management.WeeklyOverrideEditing +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField + +/** Weekly-table dialogs: evidence explanations, manual cell edits, the guide, and sharing. */ +internal object WeeklyDialogs { + /** Explains every metric of one member-day (or weekly total) in a single sheet. */ + fun showEvidence( + activity: Activity, + title: String, + isGunsmokeWeek: Boolean, + display: CellDisplay, + explanations: Map, + ) { + val metrics = buildList { + add(Metric.MERIT to display.merit) + if (isGunsmokeWeek) { + add(Metric.SCORE to (display.score ?: "-")) + add(Metric.ATTEMPTS to (display.attempts ?: "-")) + } + add(Metric.LOGIN to display.login.symbol) + add(Metric.DAILY_PATROL to display.patrol.symbol) + } + val content = Dialogs.content(activity) { + metrics.forEachIndexed { index, (metric, value) -> + val explanation = explanations[metric] ?: return@forEachIndexed + if (index > 0) addView(activity.divider(), activity.lp(top = 12, bottom = 12)) + addView(activity.row { + addView(activity.text(metricLabel(activity, metric), TextRole.SUBTITLE), activity.lp(width = 0, weight = 1f)) + addView(activity.text(value, TextRole.TITLE) { gravity = Gravity.END }, activity.lp(WRAP, WRAP, start = 8)) + }, activity.lp()) + addView(certaintyBadge(activity, explanation.certainty), activity.lp(WRAP, WRAP, top = 4)) + explanation.facts.ifEmpty { listOf(WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION) }.forEach { fact -> + addView(activity.text("• " + activity.getString(factString(fact)), TextRole.SECONDARY), activity.lp(top = 4)) + } + } + } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(title) + .setView(content) + .setPositiveButton(R.string.done, null), + ) + } + + /** + * Edits one member-day. Fields start from the displayed values; only fields the user changes + * become edits, so untouched derived values keep their certainty. + */ + fun showEdit( + activity: Activity, + title: String, + isGunsmokeWeek: Boolean, + uid: Long, + cell: WeeklyReportBuilder.DayCell, + current: WeeklyOverrideEditing.CellEdit?, + next: (() -> Unit)? = null, + onDone: (WeeklyOverrideEditing.CellEdit) -> Unit, + ) { + fun numberField(label: Int, initial: String, hint: Int): Pair { + val field = activity.textField(activity.getString(hint), inputType = InputType.TYPE_CLASS_NUMBER).apply { setText(initial) } + return LabeledField(activity, activity.getString(label), field) to initial + } + val (merit, meritInitial) = numberField(R.string.merit_short, current?.merit ?: cell.meritDelta?.toString().orEmpty(), R.string.blank_clears) + val score = if (isGunsmokeWeek) { + numberField(R.string.point_short, current?.score ?: cell.scoreDelta?.toString().orEmpty(), R.string.blank_clears) + } else { + null + } + val attempts = if (isGunsmokeWeek) { + numberField(R.string.attempt_short, current?.attempts ?: cell.attempts?.toString().orEmpty(), R.string.attempts_range) + } else { + null + } + val loginInitial = current?.attended ?: Choice.of(cell.attended) + val patrolInitial = current?.dailyPatrol ?: Choice.of(cell.dailyPatrol) + var login = loginInitial + var patrol = patrolInitial + val error = activity.errorLine() + val clear = activity.button(activity.getString(R.string.clear_manual_values), Controls.Role.DESTRUCTIVE_TEXT) {} + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.manual_edit_hint), TextRole.SECONDARY), activity.lp()) + addView(merit, activity.lp(top = 12)) + if (score != null && attempts != null) { + addView(activity.row(Gravity.TOP) { + addView(score.first, activity.lp(width = 0, weight = 1f)) + addView(attempts.first, activity.lp(width = 0, weight = 1f, start = 12)) + }, activity.lp(top = 12)) + } + addView(choiceRow(activity, R.string.login_short, loginInitial) { login = it }, activity.lp(top = 12)) + addView(choiceRow(activity, R.string.patrol_short, patrolInitial) { patrol = it }, activity.lp(top = 12)) + addView(error, activity.lp(top = 8)) + if (cell.manualOverride != null) addView(clear, activity.lp(WRAP, WRAP, top = 4)) + } + // The neutral slot walks the member's week so a whole row can be corrected in one pass. + val builder = Dialogs.builder(activity) + .setTitle(title) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.apply, null) + if (next != null) builder.setNeutralButton(R.string.apply_and_next_day, null) + Dialogs.show(builder) { dialog -> + fun changed(field: Pair?): String? = + field?.let { (view, initial) -> view.input.text.toString().takeIf { it != initial } } + fun finish(edit: WeeklyOverrideEditing.CellEdit, then: (() -> Unit)? = null) { + val probe = WeeklyOverrideEditing.merge( + cell.manualOverride?.periodStart ?: cell.gameDay, + isGunsmokeWeek, + emptyList(), + listOf(edit), + ) + if (probe is WeeklyOverrideEditing.Result.Invalid) { + error.showMessage(activity.getString( + when (probe.field) { + WeeklyOverrideEditing.Field.ATTEMPTS -> R.string.invalid_attempts + WeeklyOverrideEditing.Field.ACTIVITY -> R.string.invalid_activity_choice + else -> R.string.invalid_counter + }, + )) + return + } + dialog.dismiss() + onDone(edit) + then?.invoke() + } + fun edited() = WeeklyOverrideEditing.CellEdit( + uid = uid, + gameDay = cell.gameDay, + merit = changed(merit to meritInitial) ?: current?.merit, + score = changed(score) ?: current?.score, + attempts = changed(attempts) ?: current?.attempts, + attended = login.takeIf { it != loginInitial } ?: current?.attended, + dailyPatrol = patrol.takeIf { it != patrolInitial } ?: current?.dailyPatrol, + ) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { finish(edited()) } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { finish(edited(), next) } + clear.setOnClickListener { + finish( + WeeklyOverrideEditing.CellEdit( + uid = uid, + gameDay = cell.gameDay, + merit = "", + score = if (isGunsmokeWeek) "" else null, + attempts = if (isGunsmokeWeek) "" else null, + attended = Choice.UNSET, + dailyPatrol = Choice.UNSET, + ), + ) + } + } + } + + private fun choiceRow(activity: Activity, label: Int, initial: Choice, onChanged: (Choice) -> Unit): View = + activity.column { + addView(activity.text(activity.getString(label), TextRole.LABEL) { + setPadding(activity.dp(4), 0, 0, activity.dp(4)) + }, activity.lp()) + addView(Controls.segmentedControl( + context = activity, + options = listOf( + Choice.UNSET.name to activity.getString(R.string.choice_unset), + Choice.YES.name to activity.getString(R.string.yes), + Choice.NO.name to activity.getString(R.string.no), + ), + selectedValue = initial.name, + ) { onChanged(Choice.valueOf(it)) }, activity.lp()) + } + + fun showGuide(activity: Activity, isGunsmokeWeek: Boolean) { + val rows = listOf( + "90" to activity.getString(R.string.evidence_guide_exact), + "≥90" to activity.getString(R.string.evidence_guide_minimum), + "?" to activity.getString(R.string.evidence_guide_unknown), + activity.getString(R.string.login_initial) + " / " + activity.getString(R.string.patrol_initial) to + activity.getString(R.string.evidence_guide_marks), + (if (isGunsmokeWeek) activity.getString(R.string.total) else "0 / 50 / 90") to activity.getString( + if (isGunsmokeWeek) R.string.evidence_guide_gunsmoke_total else R.string.evidence_guide_standard_allocation, + ), + ) + val content = Dialogs.content(activity) { + rows.forEachIndexed { index, (symbol, description) -> + addView(activity.row(Gravity.TOP) { + addView(activity.badge(symbol, R.color.accent_surface, R.color.accent_text).apply { + textSize = 15f + minWidth = activity.dp(56) + minHeight = activity.dp(32) + }, activity.lp(WRAP, WRAP, end = 12)) + addView(activity.text(description, TextRole.BODY), activity.lp(width = 0, weight = 1f)) + }, activity.lp(top = if (index == 0) 0 else 12)) + } + addView(activity.text(activity.getString(R.string.evidence_guide_gap_warning), TextRole.SECONDARY), activity.lp(top = 16)) + } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.weekly_evidence_guide) + .setView(content) + .setPositiveButton(R.string.done, null), + ) + } + + fun showShare(activity: Activity, onChosen: (WeeklyShareProjection.Privacy, Boolean) -> Unit) { + var names = true + var uids = false + var notes = false + val content = Dialogs.content(activity) { + setPadding(activity.dp(16), activity.dp(4), activity.dp(16), 0) + addView(activity.switchRow(activity.getString(R.string.share_include_member_names), null, names) { names = it }, activity.lp()) + addView(activity.switchRow(activity.getString(R.string.share_include_uids), null, uids) { uids = it }, activity.lp()) + addView(activity.switchRow( + activity.getString(R.string.share_include_private_notes), + activity.getString(R.string.share_private_notes_detail), + notes, + ) { notes = it }, activity.lp()) + addView(activity.text(activity.getString(R.string.share_privacy_notice), TextRole.CAPTION) { + setPadding(activity.dp(4), activity.dp(8), activity.dp(4), 0) + }, activity.lp()) + } + fun privacy() = WeeklyShareProjection.Privacy(names, uids, notes) + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.share_weekly_table) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setNeutralButton(R.string.save_png) { _, _ -> onChosen(privacy(), false) } + .setPositiveButton(R.string.share_png) { _, _ -> onChosen(privacy(), true) }, + ) + } + + private fun certaintyBadge(activity: Activity, certainty: MetricCertainty) = when (certainty) { + MetricCertainty.EXACT -> activity.badge(activity.getString(R.string.evidence_certainty_exact), R.color.success_surface, R.color.success_text) + MetricCertainty.LOWER_BOUND -> activity.badge(activity.getString(R.string.evidence_certainty_lower_bound), R.color.warning_surface, R.color.warning_text) + MetricCertainty.UNKNOWN -> activity.badge(activity.getString(R.string.evidence_certainty_unknown), R.color.surface_variant, R.color.text_secondary) + }.apply { maxLines = 2 } + + fun metricLabel(activity: Activity, metric: Metric): String = activity.getString( + when (metric) { + Metric.MERIT -> R.string.merit_short + Metric.SCORE -> R.string.point_short + Metric.ATTEMPTS -> R.string.attempt_short + Metric.LOGIN -> R.string.login_short + Metric.DAILY_PATROL -> R.string.patrol_short + }, + ) + + private fun factString(fact: WeeklyEvidenceAnalyzer.Fact): Int = when (fact) { + WeeklyEvidenceAnalyzer.Fact.MANUAL_OVERRIDE -> R.string.evidence_fact_manual + WeeklyEvidenceAnalyzer.Fact.EXACT_CLOSING_BOUNDARY -> R.string.evidence_fact_closing + WeeklyEvidenceAnalyzer.Fact.FINAL_GUNSMOKE_SCORE -> R.string.evidence_fact_final_score + WeeklyEvidenceAnalyzer.Fact.EXACT_DAILY_PATROL_EVENT -> R.string.evidence_fact_patrol + WeeklyEvidenceAnalyzer.Fact.LOGIN_TIMESTAMP -> R.string.evidence_fact_login + WeeklyEvidenceAnalyzer.Fact.SOLVER_CONSENSUS -> R.string.evidence_fact_solver + WeeklyEvidenceAnalyzer.Fact.DAILY_CAP_REACHED -> R.string.evidence_fact_daily_cap + WeeklyEvidenceAnalyzer.Fact.WEEKLY_CAP_REACHED -> R.string.evidence_fact_weekly_cap + WeeklyEvidenceAnalyzer.Fact.ALL_DAYS_EXACT -> R.string.evidence_fact_all_exact + WeeklyEvidenceAnalyzer.Fact.CONFIRMED_LOWER_BOUND -> R.string.evidence_fact_lower_bound + WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION -> R.string.evidence_fact_no_observation + WeeklyEvidenceAnalyzer.Fact.INCOMPLETE_BOUNDARY -> R.string.evidence_fact_incomplete_boundary + WeeklyEvidenceAnalyzer.Fact.PARTIAL_DAY -> R.string.evidence_fact_partial + WeeklyEvidenceAnalyzer.Fact.SPARSE_INFERENCE -> R.string.evidence_fact_sparse + WeeklyEvidenceAnalyzer.Fact.AMBIGUOUS_ALLOCATION -> R.string.evidence_fact_ambiguous + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt b/app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt new file mode 100644 index 0000000..c49d89e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt @@ -0,0 +1,92 @@ +package dev.gf2log.app + +import android.os.Bundle +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import java.time.LocalDate + +/** + * The unsaved manual edits of the weekly table. At most one week is edited at a time, and a draft + * belongs to the Platoon scope it was started in, so a recreated screen in another scope drops it. + */ +internal class WeeklyEditSession { + /** The week being edited, or null outside edit mode. */ + var period: LocalDate? = null + private set + private val edits = linkedMapOf, CellEdit>() + + val values: Collection get() = edits.values + val size: Int get() = edits.size + + fun isEmpty(): Boolean = edits.isEmpty() + + fun isEditing(periodStart: LocalDate): Boolean = period == periodStart + + fun start(periodStart: LocalDate) { + period = periodStart + edits.clear() + } + + fun finish() { + period = null + edits.clear() + } + + /** Drops a draft for another week than [periodStart], the week now shown. */ + fun retainFor(periodStart: LocalDate) { + if (period != null && period != periodStart) finish() + } + + operator fun get(uid: Long, gameDay: LocalDate): CellEdit? = edits[uid to gameDay] + + /** Records [edit]; an empty edit restores the cell. Ignored outside edit mode. */ + fun put(edit: CellEdit): Boolean { + if (period == null) return false + val key = edit.uid to edit.gameDay + if (edit.isEmpty) edits.remove(key) else edits[key] = edit + return true + } + + fun forMember(uid: Long): Map = + edits.values.filter { it.uid == uid }.associateBy(CellEdit::gameDay) + + fun save(state: Bundle, scopeId: String) { + val editing = period ?: return + state.putString(STATE_SCOPE, scopeId) + state.putLong(STATE_PERIOD, editing.toEpochDay()) + state.putParcelableArrayList(STATE_EDITS, ArrayList(edits.values.map(::encode))) + } + + fun restore(state: Bundle?, scopeId: String) { + if (state?.containsKey(STATE_PERIOD) != true || state.getString(STATE_SCOPE) != scopeId) return + start(LocalDate.ofEpochDay(state.getLong(STATE_PERIOD))) + @Suppress("DEPRECATION") + state.getParcelableArrayList(STATE_EDITS)?.map(::decode)?.forEach(::put) + } + + private fun encode(edit: CellEdit) = Bundle().apply { + putLong("uid", edit.uid) + putLong("day", edit.gameDay.toEpochDay()) + edit.merit?.let { putString("merit", it) } + edit.score?.let { putString("score", it) } + edit.attempts?.let { putString("attempts", it) } + edit.attended?.let { putString("attended", it.name) } + edit.dailyPatrol?.let { putString("patrol", it.name) } + } + + private fun decode(bundle: Bundle) = CellEdit( + uid = bundle.getLong("uid"), + gameDay = LocalDate.ofEpochDay(bundle.getLong("day")), + merit = bundle.getString("merit"), + score = bundle.getString("score"), + attempts = bundle.getString("attempts"), + attended = bundle.getString("attended")?.let(Choice::valueOf), + dailyPatrol = bundle.getString("patrol")?.let(Choice::valueOf), + ) + + private companion object { + const val STATE_PERIOD = "weekly.editing_period" + const val STATE_SCOPE = "weekly.editing_scope" + const val STATE_EDITS = "weekly.edits" + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt new file mode 100644 index 0000000..7a0468b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt @@ -0,0 +1,160 @@ +package dev.gf2log.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.management.WeeklyReportCsv +import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import java.io.File +import java.time.LocalDate + +/** + * Weekly-table exports: CSV documents for one or every week, clipboard CSV, and the privacy-filtered + * PNG that is shared or saved. The pending request survives recreation and process death, so a + * document picker that returns to a new instance still writes what the user asked for. + */ +internal class WeeklyExportFlow( + private val activity: ComponentActivity, + private val work: BackgroundWork, + private val repository: () -> PlatoonRepository, + private val storageId: () -> String, +) { + /** What a PNG is rendered from: the table as currently ordered and annotated on screen. */ + class Source( + val report: WeeklyReportBuilder.Report, + val displayedMembers: List, + val memberNotesByUid: Map, + ) + + private var pendingCsv: String? = null + private var pendingPng: File? = null + + private val csvDocument = activity.registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> + val request = pendingCsv + pendingCsv = null + if (uri == null || request == null) return@registerForActivityResult + work.run( + task = { + val zone = GameTimeZonePreferences.get(activity, storageId()) + val csv = if (request == EXPORT_ALL) { + WeeklyReportCsv.formatAll(repository().listAllWeeklyReports(zone)) + } else { + WeeklyReportCsv.format( + repository().buildWeeklyTableRevision(LocalDate.ofEpochDay(request.toLong()), zone).report, + ) + } + DocumentAccess.openOutput(activity.contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } + }, + ) { result -> + Feedback.show(activity, if (result.isSuccess) R.string.weekly_csv_exported else R.string.weekly_csv_export_failed) + } + } + + private val pngDocument = activity.registerForActivityResult(ActivityResultContracts.CreateDocument("image/png")) { uri -> + val source = pendingPng + pendingPng = null + if (uri == null || source == null) { + source?.delete() + return@registerForActivityResult + } + work.run( + task = { + DocumentAccess.openOutput(activity.contentResolver, uri).use { output -> + source.inputStream().use { it.copyTo(output) } + } + }, + ) { result -> + source.delete() + Feedback.show(activity, if (result.isSuccess) R.string.weekly_png_saved else R.string.weekly_png_failed) + } + } + + fun saveState(outState: Bundle) { + WeeklyPngExport.nameForState(activity.cacheDir, pendingPng)?.let { outState.putString(STATE_PENDING_PNG_NAME, it) } + pendingCsv?.let { outState.putString(STATE_PENDING_CSV, it) } + } + + fun restoreState(state: Bundle?) { + pendingPng = WeeklyPngExport.restore(activity.cacheDir, state?.getString(STATE_PENDING_PNG_NAME)) + pendingCsv = state?.getString(STATE_PENDING_CSV) + } + + fun exportWeek(periodStart: LocalDate) { + pendingCsv = periodStart.toEpochDay().toString() + csvDocument.launch("GF2logger-week-${Labels.format(Labels.FILE_DATE, periodStart)}.csv") + } + + fun exportAll() { + work.run( + task = { repository().listAllWeeklyReports(GameTimeZonePreferences.get(activity, storageId())).isNotEmpty() }, + ) { result -> + if (result.getOrDefault(false)) { + pendingCsv = EXPORT_ALL + csvDocument.launch("GF2logger-all-weekly-tables.csv") + } else { + Feedback.show(activity, R.string.no_weekly_tables_to_export) + } + } + } + + fun copyWeek(report: WeeklyReportBuilder.Report) { + Feedback.copy( + activity, + activity.getString(R.string.weekly_csv_clipboard_label), + WeeklyReportCsv.format(report), + R.string.weekly_csv_copied, + ) + } + + /** Asks for the privacy options, then shares or saves a PNG of what [source] returns then. */ + fun share(source: () -> Source?) { + if (source() == null || work.isBusy(PNG)) return + WeeklyDialogs.showShare(activity) { privacy, shareAfter -> + // A second share sheet opened by a double tap must not start another render. + val current = source() ?: return@showShare + if (work.isBusy(PNG)) return@showShare + val periodStart = current.report.periodStart + Feedback.show(activity, R.string.weekly_png_rendering) + work.run( + key = PNG, + task = { + WeeklyPngExport.render( + activity, + WeeklyShareProjection.build( + current.report, + current.displayedMembers, + current.memberNotesByUid, + privacy, + WeeklyPngExport.labels(activity), + ), + periodStart, + ) + }, + ) { result -> + val file = result.getOrNull() + when { + file == null -> Feedback.show(activity, R.string.weekly_png_failed, long = true) + shareAfter -> WeeklyPngExport.share(activity, file) + else -> { + pendingPng = file + pngDocument.launch(WeeklyPngExport.exportName(periodStart)) + } + } + } + } + } + + private companion object { + const val PNG = "png" + const val EXPORT_ALL = "all" + const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" + const val STATE_PENDING_CSV = "weekly.pending_export" + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt new file mode 100644 index 0000000..a2afeb2 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt @@ -0,0 +1,274 @@ +package dev.gf2log.app + +import dev.gf2log.app.management.ManualTextPolicy +import android.app.Activity +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.TextView +import dev.gf2log.app.management.MemberEvent +import dev.gf2log.app.management.MemberEventType +import dev.gf2log.app.management.MembershipEventPresentation +import dev.gf2log.app.management.PlatoonPeriods +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.WeeklyNote +import dev.gf2log.app.management.WeeklyNoteLimitException +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * The weekly screen's footer: the week's joins and withdrawals, its notes, and the note composer. + * The composer views live for the whole screen so a typed draft survives every re-render, and + * the draft and chosen day are saved with the screen because the RecyclerView does not save them. + */ +internal class WeeklyJournalSection( + private val activity: Activity, + private val work: BackgroundWork, + private val saveKey: String, + private val repository: () -> PlatoonRepository, + private val onChanged: () -> Unit, +) { + /** What the footer shows for one rendered week. */ + class Content( + val report: WeeklyReportBuilder.Report, + val notes: List, + val events: List, + val namesByUid: Map, + val zone: ZoneId, + ) + + private val sections = activity.column() + val view: View = activity.column { + setPadding(context.dp(16), 0, context.dp(16), context.dp(24)) + addView(sections, context.lp()) + } + + private val noteInput: EditText = activity.textField( + activity.getString(R.string.note_hint), + id = R.id.weekly_note_input, + singleLine = false, + maxLength = ManualTextPolicy.MAX_NOTE_CHARS, + ) + private val noteDayButton: TextView = activity.text(null, TextRole.BODY) { + gravity = Gravity.CENTER_VERTICAL + minHeight = context.dp(48) + setPadding(context.dp(12), 0, context.dp(8), 0) + background = context.selectableRect() + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_today, 0, R.drawable.ic_expand_more, 0) + compoundDrawablePadding = context.dp(8) + compoundDrawableTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary)) + } + private var noteDay: LocalDate? = null + + fun saveState(outState: Bundle) { + noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } + outState.putString(STATE_NOTE_DRAFT, noteInput.text.toString()) + } + + fun restoreState(state: Bundle?) { + noteDay = state?.takeIf { it.containsKey(STATE_NOTE_DAY) }?.getLong(STATE_NOTE_DAY)?.let(LocalDate::ofEpochDay) + state?.getString(STATE_NOTE_DRAFT)?.let(noteInput::setText) + } + + fun clear() = sections.removeAllViews() + + fun render(content: Content) { + sections.removeAllViews() + renderMembershipEvents(content) + sections.addView(activity.sectionHeader(activity.getString(R.string.notes)), activity.lp()) + val notesByDay = content.notes.groupBy(WeeklyNote::gameDay) + if (notesByDay.isEmpty()) { + sections.addView(placeholder(R.string.no_notes), activity.lp()) + } else { + sections.addView(dayPanel(content.report.days.filter { it in notesByDay }) { day -> + notesByDay[day].orEmpty().map(::noteRow) + }, activity.lp()) + } + sections.addView(composer(content.report, content.zone), activity.lp(top = 8)) + } + + private fun renderMembershipEvents(content: Content) { + val events = MembershipEventPresentation.deduplicate(content.events) + sections.addView(activity.sectionHeader(activity.getString(R.string.join_withdraw)), activity.lp()) + if (events.isEmpty()) { + sections.addView(placeholder(R.string.no_membership_events), activity.lp()) + return + } + val byDay = events.groupBy { MembershipEventPresentation.calendarDate(it, content.zone) } + val ordered: List = content.report.days.filter { it in byDay } + + if (null in byDay) listOf(null) else emptyList() + sections.addView(dayPanel(ordered) { day -> + byDay[day].orEmpty().map { eventRow(it, content) } + }, activity.lp()) + } + + private fun placeholder(message: Int): View = activity.text(activity.getString(message), TextRole.SECONDARY) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(8)) + } + + /** One panel grouping rows under game-day headings; a null day collects undated rows. */ + private fun dayPanel(days: List, rows: (LocalDate?) -> List): View = activity.panel(padding = 8) { + days.forEachIndexed { index, day -> + if (index > 0) addView(context.divider(), context.lp(top = 4, bottom = 4)) + val heading = day?.let { Labels.dayHeader(context, it) } ?: context.getString(R.string.unknown_date) + addView(context.text(heading, TextRole.LABEL) { + setPadding(context.dp(8), context.dp(4), context.dp(8), 0) + }, context.lp()) + rows(day).forEach { addView(it, context.lp()) } + } + } + + private fun eventRow(event: MemberEvent, content: Content): View = with(activity) { + val joined = event.type == MemberEventType.JOINED || event.type == MemberEventType.REJOINED + val label = getString(if (joined) R.string.join_label else R.string.withdraw_label) + val name = content.namesByUid[event.uid].orEmpty().ifBlank { event.note } + val identity = name.takeIf(String::isNotBlank)?.let { getString(R.string.member_name_uid, it, event.uid) } + ?: Labels.uid(this, event.uid) + val time = MembershipEventPresentation.timePrefix(event, content.zone) + row { + minimumHeight = dp(40) + setPadding(dp(8), 0, dp(8), 0) + isFocusable = true + contentDescription = listOfNotNull(label, time, identity).joinToString(". ") + addView(icon( + if (joined) R.drawable.ic_arrow_down else R.drawable.ic_arrow_up, + if (joined) R.color.success_mark else R.color.failure_mark, + sizeDp = 18, + ), lp(18, 18, end = 8)) + addView(text(label, TextRole.LABEL) { + setTextColor(getColor(if (joined) R.color.success_text else R.color.destructive_action)) + minWidth = dp(44) + }, lp(WRAP, WRAP, end = 8)) + addView(text(listOfNotNull(time, identity).joinToString(" "), TextRole.BODY) { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, lp(width = 0, weight = 1f)) + } + } + + private fun noteRow(note: WeeklyNote): View = with(activity) { + row { + minimumHeight = dp(48) + setPadding(dp(8), 0, 0, 0) + addView(text(note.text, TextRole.BODY) { setTextIsSelectable(true) }, lp(width = 0, weight = 1f)) + if (!note.isAutomatic) { + addView( + iconButton(R.drawable.ic_delete, getString(R.string.delete_note_description, note.text.take(40)), R.color.text_secondary) { + confirmDeletion(note) + }, + lp(48, 48), + ) + } + } + } + + private fun composer(report: WeeklyReportBuilder.Report, zone: ZoneId): View { + val today = PlatoonPeriods.gameDay(Instant.now(), zone) + if (noteDay !in report.days) noteDay = today.takeIf { it in report.days } ?: report.days.first() + updateNoteDay() + noteDayButton.setOnClickListener { + Dialogs.choose( + activity, + activity.getString(R.string.note_day), + report.days.map { Labels.dayHeader(activity, it) }, + report.days.indexOf(noteDay), + ) { + noteDay = report.days[it] + updateNoteDay() + } + } + (noteInput.parent as? ViewGroup)?.removeView(noteInput) + (noteDayButton.parent as? ViewGroup)?.removeView(noteDayButton) + return activity.panel(padding = 12) { + addView(context.text(context.getString(R.string.add_note), TextRole.SUBTITLE), context.lp()) + addView(noteDayButton, context.lp(top = 8)) + addView(noteInput, context.lp(top = 8)) + addView( + context.button(context.getString(R.string.add_note), Controls.Role.PRIMARY) { addNote(report) }, + context.lp(top = 8), + ) + } + } + + private fun updateNoteDay() { + val day = noteDay ?: return + noteDayButton.text = Labels.dayHeader(activity, day) + noteDayButton.contentDescription = Labels.labeled(activity, activity.getString(R.string.note_day), noteDayButton.text) + } + + private fun addNote(report: WeeklyReportBuilder.Report) { + val text = noteInput.text.toString().trim() + val day = noteDay ?: return + if (work.isBusy(saveKey)) return + if (text.isEmpty()) { + noteInput.error = activity.getString(R.string.note_required) + return + } + work.run( + key = saveKey, + task = { repository().addWeeklyNote(report.periodStart.toEpochDay(), day.toEpochDay(), text) }, + ) { result -> + result.fold( + onSuccess = { + noteInput.text = null + Feedback.show(activity, R.string.note_added) + onChanged() + }, + onFailure = { error -> + Feedback.show( + activity, + if (error is WeeklyNoteLimitException) R.string.weekly_note_limit_reached else R.string.save_failed, + ) + }, + ) + } + } + + private fun confirmDeletion(note: WeeklyNote) { + Dialogs.confirm( + activity, + activity.getString(R.string.delete_note), + note.text, + activity.getString(R.string.delete), + destructive = true, + ) { + work.run(task = { repository().deleteWeeklyNote(note.id) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(activity, R.string.note_deleted) + onChanged() + } + } + } + } + + private companion object { + const val STATE_NOTE_DAY = "weekly.note_day" + const val STATE_NOTE_DRAFT = "weekly.note_draft" + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt b/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt new file mode 100644 index 0000000..38153b9 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt @@ -0,0 +1,108 @@ +package dev.gf2log.app + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.core.content.FileProvider +import dev.gf2log.app.management.WeeklyShareProjection +import java.io.File +import java.io.FileOutputStream +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * Renders privacy-filtered weekly PNGs into a private share cache. Each render receives a fresh + * UUID-backed name; stale FileProvider grants are revoked before their files are removed, and only + * the validated cache name survives Activity recreation. + */ +internal object WeeklyPngExport { + fun directory(cacheDirectory: File): File = File(cacheDirectory, DIRECTORY_NAME) + + /** Image labels in the app's active language. */ + fun labels(context: Context) = WeeklyShareProjection.Labels( + standardTitle = context.getString(R.string.png_title, context.getString(R.string.off_week)), + gunsmokeTitle = context.getString(R.string.png_title, context.getString(R.string.gunsmoke_week)), + member = context.getString(R.string.member), + total = context.getString(R.string.total), + privateNote = context.getString(R.string.member_private_note), + anonymousMember = { context.getString(R.string.png_anonymous_member, it) }, + merit = context.getString(R.string.merit_short), + score = context.getString(R.string.point_short), + attempts = context.getString(R.string.attempt_short), + login = context.getString(R.string.login_short), + patrol = context.getString(R.string.patrol_short), + ) + + fun exportName(periodStart: LocalDate): String = "GF2logger-week-${periodStart.format(DATE)}.png" + + fun render(context: Context, document: WeeklyShareProjection.Document, periodStart: LocalDate): File { + val directory = directory(context.cacheDir).apply { mkdirs() } + require(directory.isDirectory) { "Unable to create weekly share cache" } + revokeAndDeleteOld(context, directory) + val target = newRenderTarget(context.cacheDir, periodStart) + val temporary = File.createTempFile(".weekly-", ".png", directory) + val bitmap = WeeklyReportPngRenderer.render(context, document) + try { + FileOutputStream(temporary).use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) { "Unable to encode weekly PNG" } + output.fd.sync() + } + check(temporary.renameTo(target)) { "Unable to publish weekly PNG" } + } finally { + bitmap.recycle() + temporary.delete() + } + return target + } + + fun share(context: Context, file: File) { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + val title = context.getString(R.string.share_weekly_table) + val share = Intent(Intent.ACTION_SEND) + .setType("image/png") + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + share.clipData = ClipData.newUri(context.contentResolver, title, uri) + context.startActivity(Intent.createChooser(share, title)) + } + + internal fun newRenderTarget(cacheDirectory: File, periodStart: LocalDate): File { + val root = directory(cacheDirectory).canonicalFile + require(root.isDirectory) { "Weekly PNG cache is unavailable" } + val candidate = File(root, "GF2logger-week-${periodStart.format(DATE)}-${UUID.randomUUID()}.png").canonicalFile + check(candidate.parentFile == root && !candidate.exists()) { + "Weekly PNG target must be a new private cache file" + } + return candidate + } + + fun nameForState(cacheDirectory: File, pendingFile: File?): String? = runCatching { + val candidate = pendingFile?.canonicalFile ?: return@runCatching null + val root = directory(cacheDirectory).canonicalFile + candidate.name.takeIf { candidate.isFile && candidate.parentFile == root && it.matches(FILE_NAME) } + }.getOrNull() + + fun restore(cacheDirectory: File, savedName: String?): File? = runCatching { + val name = savedName?.takeIf { it.matches(FILE_NAME) } ?: return@runCatching null + val root = directory(cacheDirectory).canonicalFile + File(root, name).canonicalFile.takeIf { it.isFile && it.parentFile == root } + }.getOrNull() + + private fun revokeAndDeleteOld(context: Context, directory: File) { + directory.listFiles().orEmpty().filter(File::isFile).forEach { stale -> + runCatching { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", stale) + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + stale.delete() + } + } + + private const val DIRECTORY_NAME = "shared-weekly" + private val DATE: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + private val FILE_NAME = Regex( + "GF2logger-week-\\d{8}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.png", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index ec50bb9..8a601bf 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -1,162 +1,123 @@ package dev.gf2log.app -import android.app.AlertDialog import android.app.DatePickerDialog -import android.content.ClipData -import android.content.ClipboardManager import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable import android.os.Bundle -import android.text.Editable -import android.text.InputType -import android.text.SpannableString -import android.text.Spanned -import android.text.TextWatcher -import android.text.style.ForegroundColorSpan import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText import android.widget.FrameLayout -import android.widget.HorizontalScrollView -import android.widget.ImageButton -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Spinner -import android.widget.TextView -import android.widget.Toast -import androidx.core.content.FileProvider +import androidx.activity.OnBackPressedCallback +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.management.MemberEvent import dev.gf2log.app.management.PlatoonPeriods import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.MemberEvent -import dev.gf2log.app.management.MemberEventType -import dev.gf2log.app.management.EvidenceSource -import dev.gf2log.app.management.MembershipEventPresentation -import dev.gf2log.app.management.DailyEvidence -import dev.gf2log.app.management.MetricCertainty import dev.gf2log.app.management.WeeklyCellOverride +import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.management.WeeklyEvidenceAnalyzer +import dev.gf2log.app.management.WeeklyMemberNameProjection import dev.gf2log.app.management.WeeklyNote -import dev.gf2log.app.management.WeeklyNoteLimitException +import dev.gf2log.app.management.WeeklyOverrideEditing import dev.gf2log.app.management.WeeklyReportBuilder -import dev.gf2log.app.management.WeeklyReportCsv import dev.gf2log.app.management.WeeklyReportStateHolder -import dev.gf2log.app.management.WeeklyShareProjection -import dev.gf2log.app.management.WeeklyMetricPresentation -import dev.gf2log.app.management.WeeklyMemberNameProjection -import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text import java.time.Instant -import java.io.File -import java.io.FileOutputStream import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.UUID -import java.util.concurrent.Executors - -/** Keeps a pending weekly PNG bound to the app-private share cache across recreation. */ -internal object WeeklyPngPendingState { - fun directory(cacheDirectory: File): File = File(cacheDirectory, DIRECTORY_NAME) - - fun newRenderTarget(cacheDirectory: File, periodStart: LocalDate): File { - val root = directory(cacheDirectory).canonicalFile - require(root.isDirectory) { "Weekly PNG cache is unavailable" } - val candidate = File( - root, - "GF2logger-week-${periodStart.format(DATE)}-${UUID.randomUUID()}.png", - ).canonicalFile - check(candidate.parentFile == root && !candidate.exists()) { - "Weekly PNG target must be a new private cache file" - } - return candidate - } - - fun exportName(periodStart: LocalDate): String = - "GF2logger-week-${periodStart.format(DATE)}.png" - fun nameForState(cacheDirectory: File, pendingFile: File?): String? = runCatching { - val candidate = pendingFile?.canonicalFile ?: return@runCatching null - val root = directory(cacheDirectory).canonicalFile - candidate.name.takeIf { - candidate.isFile && candidate.parentFile == root && it.matches(FILE_NAME) - } - }.getOrNull() - - fun restore(cacheDirectory: File, savedName: String?): File? = runCatching { - val name = savedName?.takeIf { it.matches(FILE_NAME) } ?: return@runCatching null - val root = directory(cacheDirectory).canonicalFile - File(root, name).canonicalFile.takeIf { candidate -> - candidate.isFile && candidate.parentFile == root - } - }.getOrNull() - - private const val DIRECTORY_NAME = "shared-weekly" - private val DATE = DateTimeFormatter.BASIC_ISO_DATE - private val FILE_NAME = Regex( - "GF2logger-week-\\d{8}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.png", - ) -} +/** + * The weekly table: a frozen member column, synced horizontal day columns, and a sticky header in + * one vertical list. Tapping a cell explains its evidence; edit mode turns taps into manual edits. + */ +class WeeklyReportActivity : BaseActivity() { -class WeeklyReportActivity : LocalizedActivity() { - private lateinit var repository: PlatoonRepository private lateinit var profileBinding: ActivePlatoonScopeBinding - private lateinit var body: LinearLayout + private lateinit var scope: PlatoonStorageScope + private lateinit var repository: PlatoonRepository private lateinit var reportState: WeeklyReportStateHolder - private var pendingCsv: String? = null - private var pendingPng: File? = null - private val workerExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2WeeklyWorker") + private lateinit var screen: Screen + private lateinit var list: RecyclerView + private lateinit var stickyHeader: FrameLayout + private lateinit var journal: WeeklyJournalSection + private val work = BackgroundWork(this, "GF2Weekly") + private val exports = WeeklyExportFlow(this, work, { repository }, { scope.storageId }) + + private val topAdapter = SingleViewAdapter() + private val headerAdapter = SingleViewAdapter() + private val rowsAdapter = RowsAdapter() + private val stateAdapter = SingleViewAdapter() + private val bottomAdapter = SingleViewAdapter() + + private var model: RenderModel? = null + private var table: WeeklyTable? = null + + /** One table per week kind, reused across renders so recycled rows and scroll position survive. */ + private val tables = HashMap() + private val horizontalSync = HorizontalSync() + private val edits = WeeklyEditSession() + private val editingBack = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = cancelEditing() } - private var editingPeriodStart: LocalDate? = null - private val editDraft = mutableMapOf() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - val scope = profileBinding.scope ?: run { - TransientMessage.show(this, R.string.no_platoon_detected_detail, Toast.LENGTH_LONG) + scope = profileBinding.scope ?: run { + Feedback.show(this, R.string.no_platoon_detected_detail, long = true) finish() return } repository = PlatoonRepository(this, scope) - pendingPng = WeeklyPngPendingState.restore( - cacheDir, - savedInstanceState?.getString(STATE_PENDING_PNG_NAME), - ) - reportState = WeeklyReportStateHolder( - savedInstanceState?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } - ?.getLong(STATE_REFERENCE_DAY) - ?.let(LocalDate::ofEpochDay) - ?: PlatoonPeriods.gameDay( - Instant.now(), - GameTimeZonePreferences.get(this, scope.storageId), - ), - ) - body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) + journal = WeeklyJournalSection(this, work, SAVE, { repository }, ::requestRender) + restoreState(savedInstanceState) + onBackPressedDispatcher.addCallback(this, editingBack) + screen = Screen(this, PrimaryNavigation.Destination.WEEKLY) + bottomAdapter.view = journal.view + list = RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(topAdapter, headerAdapter, rowsAdapter, stateAdapter, bottomAdapter) + itemAnimator = null + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = updateStickyHeader() + }) } - setContentView( - PrimaryNavigation.wrap( - this, - ScrollView(this).apply { addView(body, matchWidth()) }, - PrimaryNavigation.Destination.WEEKLY, - ), - ) + stickyHeader = FrameLayout(this).apply { visibility = View.GONE } + screen.setBody(list) + (list.parent as ViewGroup).addView(stickyHeader, FrameLayout.LayoutParams(MATCH, WRAP, Gravity.TOP)) + updateTopBar() } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { recreate() return @@ -166,60 +127,65 @@ class WeeklyReportActivity : LocalizedActivity() { } override fun onPause() { - reportState.onPause() + if (::reportState.isInitialized) reportState.onPause() super.onPause() } + override fun onSaveInstanceState(outState: Bundle) { - outState.putLong(STATE_REFERENCE_DAY, reportState.referenceDay.toEpochDay()) - WeeklyPngPendingState.nameForState(cacheDir, pendingPng)?.let { - outState.putString(STATE_PENDING_PNG_NAME, it) + if (::reportState.isInitialized) { + outState.putLong(STATE_REFERENCE_DAY, reportState.referenceDay.toEpochDay()) } + exports.saveState(outState) + if (::scope.isInitialized) edits.save(outState, scope.storageId) + if (::journal.isInitialized) journal.saveState(outState) super.onSaveInstanceState(outState) } - - override fun onDestroy() { - workerExecutor.shutdownNow() - super.onDestroy() + private fun restoreState(state: Bundle?) { + exports.restoreState(state) + reportState = WeeklyReportStateHolder( + state?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } + ?.getLong(STATE_REFERENCE_DAY) + ?.let(LocalDate::ofEpochDay) + ?: PlatoonPeriods.gameDay(Instant.now(), GameTimeZonePreferences.get(this, scope.storageId)), + ) + // Edits belong to one Platoon; a profile switch recreates this screen in another scope. + edits.restore(state, scope.storageId) + journal.restoreState(state) } - // Function Name: requestRender - // Description: - // - Loads the weekly projection and all supporting database state away from the main thread. - // - Uses a generation token so late results cannot replace a newer week or a paused screen. - // Parameters: - // - reconcileRetainedCsv: Whether retained CSV files must be reconciled before loading. - // Returns: - // - Returns immediately after scheduling the load. + // region Loading and rendering + private fun requestRender(reconcileRetainedCsv: Boolean = false) { val request = reportState.newRenderRequest() - showLoading() - workerExecutor.execute { - val result = runCatching { + if (model == null) stateAdapter.view = StateViews.loading(this, getString(R.string.weekly_report_loading)) + work.run( + key = RENDER, + task = { if (reconcileRetainedCsv) repository.reconcileRetainedCsvFiles() loadRenderModel(request.referenceDay) - } - runOnUiThread { - if (!canApplyRender(request.generation)) return@runOnUiThread - result.fold( - onSuccess = { render(it) }, - onFailure = { showLoadFailure() }, - ) - } + }, + ) { result -> + result.fold( + onSuccess = ::render, + onFailure = { + model = null + topAdapter.view = null + headerAdapter.view = null + stickyHeader.visibility = View.GONE + rowsAdapter.submit(emptyList()) + journal.clear() + stateAdapter.view = StateViews.failure(this, getString(R.string.weekly_report_load_failed)) { + requestRender() + } + updateTopBar() + }, + ) } } - // Function Name: loadRenderModel - // Description: - // - Reads every repository and preference value needed by one weekly screen render. - // - Keeps later view construction free of database access and N+1 member membershipPeriod queries. - // Parameters: - // - targetDay: A date inside the requested reporting week. - // Returns: - // - An immutable render model for the requested week. private fun loadRenderModel(targetDay: LocalDate): RenderModel { - val zone = GameTimeZonePreferences.get(this) - val periodStart = PlatoonPeriods.weekStart(targetDay) + val zone = GameTimeZonePreferences.get(this, scope.storageId) val revision = repository.buildWeeklyTableRevision(targetDay, zone) val report = revision.report val history = repository.listWeeklyReportHistory(report.periodStart) @@ -232,1563 +198,405 @@ class WeeklyReportActivity : LocalizedActivity() { reportNamesByUid = report.members.associate { it.uid to it.name }, capturedNamesByUid = revision.memberNamesByUid, ), - cutlines = WeeklyCutlinePreferences(this).read(), + cutlines = WeeklyCutlinePreferences(this, scope.storageId).read(), memberNotesByUid = revision.memberPrivateNotesByUid, - displayedMembers = MemberOrderPreferences(this).apply(report.members) { it.uid }, + displayedMembers = MemberOrderPreferences(this, scope.storageId).apply(report.members) { it.uid }, scoreRanks = report.members.withIndex().associate { it.value.uid to it.index + 1 }, historyCount = history.size, restoredHistory = history.any { it.active }, + currentWeekStart = PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(Instant.now(), zone)), ) } - private fun canApplyRender(generation: Int): Boolean = - reportState.canApply(generation) && !isFinishing && !isDestroyed - - private fun showLoading() { - body.removeAllViews() - body.addView(TextView(this).apply { - text = getString(R.string.weekly_report_loading) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(32)) - }, matchWidth()) - } - - private fun showLoadFailure() { - body.removeAllViews() - body.addView(TextView(this).apply { - text = getString(R.string.weekly_report_load_failed) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(12)) - }, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.retry) - setOnClickListener { requestRender() } - }, matchWidth()) - } - - // Function Name: render - // Description: - // - Builds the weekly screen from an already-loaded immutable model. - // - Delegates member rows to a bounded RecyclerView viewport. - // Parameters: - // - model: Repository and preference state for one reporting week. - // Returns: - // - Unit after building the current screen projection. - private fun render(model: RenderModel) { - body.removeAllViews() - val zone = model.zone - val report = model.report - val isEditing = editingPeriodStart == report.periodStart - - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { - text = getString( - if (report.isGunsmokeWeek) R.string.gunsmoke_week else R.string.off_week, - ) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_history_restore) - useModernIconStyle() - contentDescription = getString(R.string.weekly_table_history) - setPadding(dp(10), dp(10), dp(10), dp(10)) - isEnabled = !isEditing && model.historyCount > 0 - alpha = if (isEnabled) 1f else 0.35f - setOnClickListener { - startActivity( - Intent(this@WeeklyReportActivity, WeeklyTableHistoryActivity::class.java) - .putExtra( - WeeklyTableHistoryActivity.EXTRA_PERIOD_START, - report.periodStart.toEpochDay(), - ), - ) - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(8) }) - addView(ImageButton(context).apply { - setImageResource(if (isEditing) R.drawable.ic_save else R.drawable.ic_edit) - useModernIconStyle() - contentDescription = getString( - if (isEditing) R.string.save_weekly_edits else R.string.edit_weekly_table, - ) - setPadding(dp(10), dp(10), dp(10), dp(10)) - setOnClickListener { - if (isEditing) { - saveWeeklyEdits(report) - } else { - showManualEditWarning(report) - } - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(8) }) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_share) - useModernIconStyle() - contentDescription = getString(R.string.share_weekly_table) - setPadding(dp(10), dp(10), dp(10), dp(10)) - isEnabled = !isEditing && report.members.isNotEmpty() - alpha = if (isEnabled) 1f else 0.35f - setOnClickListener { - showWeeklyShareOptions(model) - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(8) }) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_settings) - useModernIconStyle() - contentDescription = getString(R.string.weekly_table_settings) - setPadding(dp(10), dp(10), dp(10), dp(10)) - isEnabled = !isEditing - alpha = if (isEditing) 0.35f else 1f - setOnClickListener { - startActivity( - Intent(this@WeeklyReportActivity, WeeklySettingsActivity::class.java), - ) - } - }, LinearLayout.LayoutParams(dp(48), dp(48))) - }, matchWidth()) - body.addView( - PlatoonProfileSelector.controls(this), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(6) }, - ) - body.addView(TextView(this).apply { - text = getString( - R.string.week_period, - report.periodStart.format(DATE), - report.periodEnd.format(DATE), - zone.id, - ) - textSize = 14f - setTextColor(getColor(R.color.text_secondary)) - }, matchWidth()) - if (model.restoredHistory) { - body.addView(TextView(this).apply { - text = getString(R.string.weekly_table_history_live_notice) - textSize = 13f - setTextColor(getColor(R.color.accent_text)) - background = ModernUi.panelBackground(context, emphasized = true) - setPadding(dp(12), dp(10), dp(12), dp(10)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - } - if (report.hasIncompleteDailyEvidence) { - body.addView(ModernUi.actionRow( - context = this, - title = getString(R.string.weekly_evidence_guide), - detail = getString(R.string.weekly_evidence_guide_summary), - onClick = { showWeeklyEvidenceGuide(report.isGunsmokeWeek) }, - ), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - } - addEvidenceHealthPanel(report) - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(context).apply { - text = "\u2039" - useTertiaryActionStyle() - isEnabled = !isEditing - contentDescription = getString(R.string.previous_week) - setOnClickListener { - reportState.showPreviousWeek(report.periodStart) - requestRender() - } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(Button(context).apply { - text = getString(R.string.current_week) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { showDatePicker() } - }, LinearLayout.LayoutParams(0, wrap(), 2f)) - addView(Button(context).apply { - text = "\u203a" - useTertiaryActionStyle() - isEnabled = !isEditing - contentDescription = getString(R.string.next_week) - setOnClickListener { - reportState.showNextWeek(report.periodEnd) - requestRender() - } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - }, matchWidth()) - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(context).apply { - text = getString(R.string.export_weekly_csv) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { exportWeeklyCsv(report) } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(Button(context).apply { - text = getString(R.string.copy_weekly_csv) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { copyWeeklyCsv(report) } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - }, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.export_all_weekly_tables) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { exportAllWeeklyTables() } - }, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.edit_member_order) - useNavigationActionStyle() - isEnabled = !isEditing - setOnClickListener { - startActivity(Intent(this@WeeklyReportActivity, MemberOrderActivity::class.java)) - } - }, matchWidth()) - + private fun render(next: RenderModel) { + val report = next.report + edits.retainFor(report.periodStart) + model = next + val weeklyTable = tables.getOrPut(report.isGunsmokeWeek) { + WeeklyTable(this, report.isGunsmokeWeek, next.zone, next.cutlines, horizontalSync) + }.apply { + zone = next.zone + cutlines = next.cutlines + now = Instant.now() + } + table = weeklyTable + topAdapter.view = buildTop(next) + stickyHeader.removeAllViews() if (report.members.isEmpty()) { - body.addView(TextView(this).apply { - text = getString(R.string.no_weekly_data) - setPadding(0, dp(16), 0, dp(16)) - }, matchWidth()) - } else { - body.addView(buildVirtualizedTable(model, isEditing), matchWidth()) - } - - if (isEditing) return - - addMembershipEvents(report, model.events, model.namesByUid, zone) - body.addView(TextView(this).apply { - text = getString(R.string.notes) - textSize = 21f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(16), 0, dp(4)) - }, matchWidth()) - report.days.forEach { day -> - val dayNotes = model.notes.filter { it.gameDay == day } - if (dayNotes.isNotEmpty()) { - body.addView(TextView(this).apply { - text = day.format(DATE) - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - dayNotes.forEach(::addNote) - } - } - addNoteEditor(report) - } - - - - // Function Name: buildVirtualizedTable - // Description: - // - Keeps the shared header and member rows in one horizontal coordinate space. - // - Bounds the vertical viewport so RecyclerView recycles expensive metric rows. - // Parameters: - // - model: Immutable data and display ordering for the selected week. - // - isEditing: Whether rows contain editable fields. - // Returns: - // - A horizontally scrollable table with a virtualized member list. - private fun buildVirtualizedTable( - model: RenderModel, - isEditing: Boolean, - ) = HorizontalScrollView(this).apply { - isFillViewport = false - val report = model.report - val rowHeight = dp(metricGroupHeight(report.isGunsmokeWeek)) - val tableWidth = dp( - (if (report.isGunsmokeWeek) RANK_WIDTH else 0) + - MEMBER_WIDTH + DAILY_WIDTH * (report.days.size + 1), - ) - val visibleRows = minOf( - MAX_VISIBLE_TABLE_ROWS, - model.displayedMembers.size, - ).coerceAtLeast(1) - val rows = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView( - headerRow(report), - LinearLayout.LayoutParams(tableWidth, dp(HEADER_HEIGHT)), - ) - addView( - RecyclerView(context).apply { - layoutManager = LinearLayoutManager(context) - adapter = WeeklyMemberAdapter( - model = model, - isEditing = isEditing, - tableWidth = tableWidth, - rowHeight = rowHeight, - ) - itemAnimator = null - setHasFixedSize(true) - }, - LinearLayout.LayoutParams(tableWidth, rowHeight * visibleRows), - ) - } - addView(rows) - } - - private inner class WeeklyMemberAdapter( - private val model: RenderModel, - private val isEditing: Boolean, - private val tableWidth: Int, - private val rowHeight: Int, - ) : RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): WeeklyMemberViewHolder = WeeklyMemberViewHolder( - FrameLayout(parent.context).apply { - layoutParams = RecyclerView.LayoutParams(tableWidth, rowHeight) - }, - ) - - override fun getItemCount(): Int = model.displayedMembers.size - - override fun onBindViewHolder(holder: WeeklyMemberViewHolder, position: Int) { - val member = model.displayedMembers[position] - holder.container.removeAllViews() - holder.container.addView( - memberRow( - member = member, - rank = model.scoreRanks[member.uid], - gunsmokeWeek = model.report.isGunsmokeWeek, - cutlines = model.cutlines, - isEditing = isEditing, - zoneId = model.zone, - ), - FrameLayout.LayoutParams(tableWidth, rowHeight), + headerAdapter.view = null + stateAdapter.view = StateViews.empty( + this, + R.drawable.ic_calendar, + getString(R.string.no_weekly_data_title), + getString(R.string.no_weekly_data), ) + } else { + stateAdapter.view = null + headerAdapter.view = weeklyTable.headerRow(report) + stickyHeader.addView(weeklyTable.headerRow(report)) } - - override fun onViewRecycled(holder: WeeklyMemberViewHolder) { - holder.container.removeAllViews() - super.onViewRecycled(holder) - - } - } - - private class WeeklyMemberViewHolder( - val container: FrameLayout, - ) : RecyclerView.ViewHolder(container) - - private fun headerRow(report: WeeklyReportBuilder.Report) = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - if (report.isGunsmokeWeek) { - addView(gridCell(getString(R.string.rank), RANK_WIDTH, HEADER_HEIGHT, header = true)) - } - addView(gridCell(getString(R.string.member), MEMBER_WIDTH, HEADER_HEIGHT, header = true)) - report.days.forEach { - addView(gridCell(it.format(DAY), DAILY_WIDTH, HEADER_HEIGHT, header = true)) + rowsAdapter.submit(next.displayedMembers) + if (edits.isEditing(report.periodStart)) { + journal.clear() + } else { + journal.render(WeeklyJournalSection.Content(report, next.notes, next.events, next.namesByUid, next.zone)) } - addView(gridCell(getString(R.string.total), DAILY_WIDTH, HEADER_HEIGHT, header = true)) + updateTopBar() + list.post(::updateStickyHeader) } - private fun memberRow( - member: WeeklyReportBuilder.MemberRow, - rank: Int?, - gunsmokeWeek: Boolean, - cutlines: WeeklyCutlines, - isEditing: Boolean, - zoneId: ZoneId, - ) = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - val rowHeight = metricGroupHeight(gunsmokeWeek) - if (gunsmokeWeek) { - addView(gridCell(rank?.toString().orEmpty(), RANK_WIDTH, rowHeight)) - } - addView(gridCell("${member.name}\n#${member.uid}", MEMBER_WIDTH, rowHeight, textSize = 15f)) - member.days.forEach { cell -> - addView( - if (isEditing) { - editableDailyMetricGroup(member.uid, cell, gunsmokeWeek) - } else { - dailyMetricGroup( - memberName = member.name, - cell = cell, - gunsmokeWeek = gunsmokeWeek, - dayClosed = !PlatoonPeriods.periodStartInstant( - cell.gameDay.plusDays(1), - zoneId, - ).isAfter(Instant.now()), - cutlines = cutlines, - ) - }, - ) + private fun updateStickyHeader() { + val manager = list.layoutManager as? LinearLayoutManager ?: return + if (headerAdapter.view == null) { + stickyHeader.visibility = View.GONE + return } - addView(totalMetricGroup(member.name, member, gunsmokeWeek, cutlines)) - } - - private fun editableDailyMetricGroup( - uid: Long, - cell: WeeklyReportBuilder.DayCell, - gunsmokeWeek: Boolean, - ): LinearLayout { - val key = CellKey(uid, cell.gameDay) - val draft = requireNotNull(editDraft[key]) { "Missing weekly edit draft for $key" } - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - val groupHeight = metricGroupHeight(gunsmokeWeek) - layoutParams = LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(groupHeight)) - addView( - editableNumberCell( - label = getString(R.string.merit_short), - value = draft.merit, - width = DAILY_WIDTH, - ) { - draft.merit = it - draft.meritDirty = true - }, - LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT)), - ) - if (gunsmokeWeek) { - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false + val headerPosition = topAdapter.itemCount + val lastRowPosition = headerPosition + rowsAdapter.itemCount + val first = manager.findFirstVisibleItemPosition() + val headerTop = manager.findViewByPosition(headerPosition)?.top ?: 0 + val scrolledPast = first > headerPosition || (first == headerPosition && headerTop < 0) + stickyHeader.visibility = if (scrolledPast && first <= lastRowPosition) View.VISIBLE else View.GONE + } + + private fun buildTop(next: RenderModel): View { + val report = next.report + val editing = edits.isEditing(report.periodStart) + return column { + setPadding(dp(16), 0, dp(16), dp(12)) + addView(PlatoonProfileSelector.card(this@WeeklyReportActivity, compact = true).apply { + // Switching Platoons mid-edit would discard the draft without confirmation. + isEnabled = !editing + alpha = if (editing) 0.6f else 1f + }, lp(top = 4)) + addView(weekNavigator(next, editing), lp(top = 12)) + if (next.restoredHistory) { + addView(panel(emphasized = true, padding = 12) { + addView(text(getString(R.string.weekly_table_history_live_notice), TextRole.SECONDARY) { + setTextColor(getColor(R.color.accent_text)) + }, lp()) addView( - editableNumberCell( - label = getString(R.string.point_short), - value = draft.score, - width = DAILY_WIDTH / 2, - ) { - draft.score = it - draft.scoreDirty = true + button(getString(R.string.show_live_table), Controls.Role.SECONDARY) { + showLiveTable(report.periodStart) }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), + lp(WRAP, WRAP, top = 8), ) - addView( - editableNumberCell( - label = getString(R.string.attempt_short), - value = draft.attempts, - width = DAILY_WIDTH / 2, - ) { - draft.attempts = it - draft.attemptsDirty = true - }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) + }, lp(top = 8)) } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - editableStatusCell( - getString(R.string.login_short), - draft.attended, - DAILY_WIDTH / 2, - ) { value -> - draft.attended = value - draft.attendedDirty = true - }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - addView( - editableStatusCell( - getString(R.string.patrol_short), - draft.dailyPatrol, - DAILY_WIDTH / 2, - ) { value -> - draft.dailyPatrol = value - draft.dailyPatrolDirty = true - }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) - } - } - - private fun editableNumberCell( - label: String, - value: String, - width: Int, - onChange: (String) -> Unit, - ) = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - setPadding(dp(2), 0, dp(2), 0) - background = gridBackground() - layoutParams = LinearLayout.LayoutParams(dp(width), dp(METRIC_HEIGHT)) - addView(TextView(context).apply { - text = label - textSize = 8f - gravity = Gravity.CENTER - }, LinearLayout.LayoutParams(matchParent(), 0, 0.38f)) - addView(EditText(context).apply { - setText(value) - textSize = 11f - gravity = Gravity.CENTER - inputType = InputType.TYPE_CLASS_NUMBER - setSingleLine(true) - setPadding(0, 0, 0, 0) - background = editableFieldBackground() - selectAll() - addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged( - text: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - text: CharSequence?, - start: Int, - before: Int, - count: Int, - ) = onChange(text?.toString().orEmpty()) - - override fun afterTextChanged(text: Editable?) = Unit - }) - }, LinearLayout.LayoutParams(matchParent(), 0, 0.62f).apply { - setMargins(dp(3), 0, dp(3), dp(2)) - }) - } - - private fun editableStatusCell( - label: String, - initialValue: Boolean?, - width: Int, - onChange: (Boolean?) -> Unit, - ) = Button(this).apply { - var value = initialValue - isAllCaps = false - textSize = 9f - setPadding(0, 0, 0, 0) - background = editableFieldBackground() - layoutParams = LinearLayout.LayoutParams(dp(width), dp(METRIC_HEIGHT)) - - fun update() { - text = statusText(label, editableActivityMark(value)) - } - update() - setOnClickListener { - value = when (value) { - null -> true - true -> false - false -> null + if (editing) { + addView(panel(emphasized = true, padding = 12) { + addView(row { + addView(icon(R.drawable.ic_edit, R.color.accent_text, sizeDp = 20), lp(20, 20, end = 8)) + addView(text(getString(R.string.weekly_edit_banner), TextRole.SECONDARY) { + setTextColor(getColor(R.color.accent_text)) + }, lp(width = 0, weight = 1f)) + }, lp()) + }, lp(top = 8)) + } else if (report.members.isNotEmpty()) { + addView(evidencePanel(report), lp(top = 8)) } - onChange(value) - update() } } - private fun dailyMetricGroup( - memberName: String, - cell: WeeklyReportBuilder.DayCell, - gunsmokeWeek: Boolean, - dayClosed: Boolean, - cutlines: WeeklyCutlines, - ): LinearLayout { - return metricGroup( - memberName = memberName, - explanations = WeeklyEvidenceAnalyzer.Metric.values().associateWith { metric -> - WeeklyEvidenceAnalyzer.explainDaily(cell, metric) - }, - showGunsmokeMetrics = gunsmokeWeek, - merit = metricText( - getString(R.string.merit_short), - WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - cell.meritDelta, - cell.meritCertainty, - cutlines::belowDailyMerit, - ), - ), - score = metricText( - getString(R.string.point_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - cell.scoreDelta, - cell.scoreCertainty, - cutlines::belowDailyScore, - ), - ), - attempts = metricText( - getString(R.string.attempt_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format( - cell.attempts, - cell.attemptsCertainty, - missing = if (cell.manualOverride != null) "-" else "?", - ) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - cell.attempts, - cell.attemptsCertainty, - cutlines::belowDailyAttempts, - ), - ), - login = statusText( - getString(R.string.login_short), - if (cell.attended == null && cell.evidence != DailyEvidence.MANUAL) { - unknownActivityMark() - } else { - activityMark(cell.attended, cell.observed, dayClosed) - }, - ), - patrol = statusText( - getString(R.string.patrol_short), - if (cell.dailyPatrol == null && cell.evidence != DailyEvidence.MANUAL) { - unknownActivityMark() - } else { - activityMark(cell.dailyPatrol, cell.observed, dayClosed) - }, - ), - ) - } - - private fun totalMetricGroup( - memberName: String, - member: WeeklyReportBuilder.MemberRow, - gunsmokeWeek: Boolean, - cutlines: WeeklyCutlines, - ): LinearLayout { - return metricGroup( - memberName = memberName, - explanations = WeeklyEvidenceAnalyzer.Metric.values().associateWith { metric -> - WeeklyEvidenceAnalyzer.explainTotal(member, metric) - }, - showGunsmokeMetrics = gunsmokeWeek, - merit = metricText( - getString(R.string.merit_short), - WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - member.totalMerit, - member.totalMeritCertainty, - cutlines::belowWeeklyMerit, - ), - ), - score = metricText( - getString(R.string.point_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - member.totalScore, - member.totalScoreCertainty, - cutlines::belowWeeklyScore, - ), - ), - attempts = metricText( - getString(R.string.attempt_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format( - member.totalAttempts, - member.totalAttemptsCertainty, - ) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - member.totalAttempts, - member.totalAttemptsCertainty, - cutlines::belowWeeklyAttempts, - ), - ), - login = metricText( - getString(R.string.login_short), - WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - member.loginDays, - member.loginDaysCertainty, - cutlines::belowWeeklyLoginDays, - ), - ), - patrol = metricText( - getString(R.string.patrol_short), - WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - member.patrolDays, - member.patrolDaysCertainty, - cutlines::belowWeeklyPatrolDays, - ), - ), - ) - } - - private fun metricGroup( - memberName: String, - explanations: Map, - showGunsmokeMetrics: Boolean, - merit: CharSequence, - score: CharSequence, - attempts: CharSequence, - login: CharSequence, - patrol: CharSequence, - ) = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - val groupHeight = metricGroupHeight(showGunsmokeMetrics) - layoutParams = LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(groupHeight)) - addView( - gridCell( - merit, - DAILY_WIDTH, - METRIC_HEIGHT, - textSize = 11f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.MERIT]), + private fun weekNavigator(next: RenderModel, editing: Boolean): View { + val report = next.report + val weekType = getString(if (report.isGunsmokeWeek) R.string.gunsmoke_week else R.string.off_week) + return panel(padding = 4) { + addView(row { + addView(iconButton(R.drawable.ic_chevron_left, getString(R.string.previous_week)) { + reportState.showPreviousWeek(report.periodStart) + requestRender() + }.apply { isEnabled = !editing }, lp(48, 48)) + addView(column { + gravity = Gravity.CENTER_HORIZONTAL + minimumHeight = dp(56) + setPadding(dp(4), dp(6), dp(4), dp(6)) + background = selectableRect() + isEnabled = !editing + setOnClickListener { showDatePicker() } + addView(text(weekType, TextRole.LABEL) { + gravity = Gravity.CENTER + setTextColor(getColor(if (report.isGunsmokeWeek) R.color.accent_text else R.color.text_secondary)) + }, lp()) + val range = getString( + R.string.week_range, + Labels.format(Labels.DATE, report.periodStart), + Labels.format(Labels.DATE, report.periodEnd), ) - }, - ), - LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT)), - ) - if (showGunsmokeMetrics) { - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - gridCell( - score, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.SCORE]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) + addView(text(range, TextRole.SUBTITLE) { gravity = Gravity.CENTER }, lp()) + addView(text(getString(R.string.week_reset_zone, next.zone.id), TextRole.CAPTION) { + gravity = Gravity.CENTER + }, lp()) + contentDescription = "$weekType. $range. ${getString(R.string.select_week)}" + }, lp(width = 0, weight = 1f)) + addView(iconButton(R.drawable.ic_chevron_right, getString(R.string.next_week)) { + reportState.showNextWeek(report.periodEnd) + requestRender() + }.apply { isEnabled = !editing }, lp(48, 48)) + }, lp()) + if (report.periodStart != next.currentWeekStart && !editing) { addView( - gridCell( - attempts, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.ATTEMPTS]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) - } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - gridCell( - login, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.LOGIN]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - addView( - gridCell( - patrol, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.DAILY_PATROL]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) - } - - private fun metricGroupHeight(showGunsmokeMetrics: Boolean): Int = - METRIC_HEIGHT * if (showGunsmokeMetrics) 3 else 2 - - private fun gridCell( - value: CharSequence, - width: Int, - height: Int, - header: Boolean = false, - textSize: Float = if (header) 12f else 11f, - onClick: (() -> Unit)? = null, - ) = TextView(this).apply { - text = value - gravity = Gravity.CENTER - this.textSize = textSize - setTextColor(getColor(R.color.text_primary)) - if (header) { - setTypeface(typeface, Typeface.BOLD) - } - setPadding(dp(4), dp(2), dp(4), dp(2)) - background = GradientDrawable().apply { - setColor(if (header) getColor(R.color.table_header) else Color.TRANSPARENT) - setStroke(1, getColor(R.color.outline_strong)) - } - layoutParams = LinearLayout.LayoutParams(dp(width), dp(height)) - onClick?.let { - isClickable = true - isFocusable = true - setOnClickListener { it() } - } - } - - private fun addEvidenceHealthPanel(report: WeeklyReportBuilder.Report) { - val health = WeeklyEvidenceAnalyzer.health(report) - val statusColor = if (health.isComplete) R.color.success_text else R.color.warning_text - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - background = GradientDrawable().apply { - setColor(getColor(R.color.surface)) - cornerRadius = dp(14).toFloat() - } - addView(View(context).apply { - setBackgroundColor(getColor(statusColor)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(dp(3), ViewGroup.LayoutParams.MATCH_PARENT)) - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(12), dp(8), dp(12), dp(8)) - addView(TextView(context).apply { - text = getString(R.string.evidence_health_title) - textSize = 14f - setTypeface(typeface, Typeface.BOLD) - setTextColor(getColor(statusColor)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString( - R.string.evidence_health_summary, - health.observedDays, - health.totalDays, - health.exactMetrics, - health.lowerBoundMetrics, - health.unknownMetrics, - health.directLoginDays, - health.directPatrolDays, - health.closingBoundaries, - ) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(2), 0, 0) - }, matchWidth()) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }) - } - - private fun showWeeklyEvidenceGuide(isGunsmokeWeek: Boolean) { - fun guideRow(symbol: String, description: CharSequence): View = - LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { - text = symbol - textSize = if (symbol.length <= 2) 20f else 15f - gravity = Gravity.CENTER - setTypeface(typeface, Typeface.BOLD) - setTextColor(getColor(R.color.accent_text)) - background = ModernUi.panelBackground(context, emphasized = true) - setPadding(dp(8), dp(8), dp(8), dp(8)) - minWidth = dp(76) - minHeight = dp(48) - maxLines = 1 - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - marginEnd = dp(12) - }) - addView(TextView(context).apply { - text = description - textSize = 14f - setTextColor(getColor(R.color.text_primary)) - setLineSpacing(0f, 1.12f) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - } - val guide = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), dp(12)) - val rows = listOf( - "✓" to getString(R.string.evidence_guide_exact), - "≥" to getString(R.string.evidence_guide_minimum), - "?" to getString(R.string.evidence_guide_unknown), - (if (isGunsmokeWeek) "TOTAL" else "0 / 50 / 90") to getString( - if (isGunsmokeWeek) { - R.string.evidence_guide_gunsmoke_total - } else { - R.string.evidence_guide_standard_allocation + button(getString(R.string.go_to_current_week), Controls.Role.TERTIARY, R.drawable.ic_today) { + reportState.selectDate(PlatoonPeriods.gameDay(Instant.now(), next.zone)) + requestRender() }, - ), - ) - rows.forEachIndexed { index, (symbol, explanation) -> - addView(guideRow(symbol, explanation), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { if (index > 0) topMargin = dp(12) }) + lp(WRAP, WRAP).apply { gravity = Gravity.CENTER_HORIZONTAL }, + ) } - addView(TextView(context).apply { - text = getString(R.string.evidence_guide_gap_warning) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setLineSpacing(0f, 1.12f) - setPadding(0, dp(16), 0, 0) - }, matchWidth()) } - AlertDialog.Builder(this) - .setTitle(R.string.weekly_evidence_guide) - .setView(ScrollView(this).apply { addView(guide, matchWidth()) }) - .setPositiveButton(android.R.string.ok, null) - .showModern() } - private fun showEvidenceExplanation( - memberName: String, - explanation: WeeklyEvidenceAnalyzer.Explanation, - ) { - val target = explanation.gameDay?.format(DATE) ?: getString(R.string.total) - val facts = explanation.facts - .ifEmpty { listOf(WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION) } - .joinToString("\n") { "\u2022 " + getString(evidenceFactString(it)) } - AlertDialog.Builder(this) - .setTitle( - getString( - R.string.evidence_explanation_title, - memberName, - target, - metricLabel(explanation.metric), - ), - ) - .setMessage( - getString( - R.string.evidence_explanation_message, - getString( - when (explanation.certainty) { - MetricCertainty.EXACT -> R.string.evidence_certainty_exact - MetricCertainty.LOWER_BOUND -> R.string.evidence_certainty_lower_bound - MetricCertainty.UNKNOWN -> R.string.evidence_certainty_unknown - }, - ), - facts, + private fun evidencePanel(report: WeeklyReportBuilder.Report): View { + val health = WeeklyEvidenceAnalyzer.health(report) + return panel(padding = 4) { + addView(navigationRow( + title = getString(if (health.isComplete) R.string.evidence_complete else R.string.evidence_incomplete), + detail = getString( + R.string.evidence_health_compact, + health.observedDays, + health.totalDays, + health.exactMetrics, + health.lowerBoundMetrics, + health.unknownMetrics, ), - ) - .setPositiveButton(android.R.string.ok, null) - .show() - } - - private fun metricLabel(metric: WeeklyEvidenceAnalyzer.Metric): String = getString( - when (metric) { - WeeklyEvidenceAnalyzer.Metric.MERIT -> R.string.merit_short - WeeklyEvidenceAnalyzer.Metric.SCORE -> R.string.point_short - WeeklyEvidenceAnalyzer.Metric.ATTEMPTS -> R.string.attempt_short - WeeklyEvidenceAnalyzer.Metric.LOGIN -> R.string.login_short - WeeklyEvidenceAnalyzer.Metric.DAILY_PATROL -> R.string.patrol_short - }, - ) - - private fun evidenceFactString(fact: WeeklyEvidenceAnalyzer.Fact): Int = when (fact) { - WeeklyEvidenceAnalyzer.Fact.MANUAL_OVERRIDE -> R.string.evidence_fact_manual - WeeklyEvidenceAnalyzer.Fact.EXACT_CLOSING_BOUNDARY -> R.string.evidence_fact_closing - WeeklyEvidenceAnalyzer.Fact.FINAL_GUNSMOKE_SCORE -> R.string.evidence_fact_final_score - WeeklyEvidenceAnalyzer.Fact.EXACT_DAILY_PATROL_EVENT -> R.string.evidence_fact_patrol - WeeklyEvidenceAnalyzer.Fact.LOGIN_TIMESTAMP -> R.string.evidence_fact_login - WeeklyEvidenceAnalyzer.Fact.SOLVER_CONSENSUS -> R.string.evidence_fact_solver - WeeklyEvidenceAnalyzer.Fact.DAILY_CAP_REACHED -> R.string.evidence_fact_daily_cap - WeeklyEvidenceAnalyzer.Fact.WEEKLY_CAP_REACHED -> R.string.evidence_fact_weekly_cap - WeeklyEvidenceAnalyzer.Fact.ALL_DAYS_EXACT -> R.string.evidence_fact_all_exact - WeeklyEvidenceAnalyzer.Fact.CONFIRMED_LOWER_BOUND -> R.string.evidence_fact_lower_bound - WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION -> R.string.evidence_fact_no_observation - WeeklyEvidenceAnalyzer.Fact.INCOMPLETE_BOUNDARY -> R.string.evidence_fact_incomplete_boundary - WeeklyEvidenceAnalyzer.Fact.PARTIAL_DAY -> R.string.evidence_fact_partial - WeeklyEvidenceAnalyzer.Fact.SPARSE_INFERENCE -> R.string.evidence_fact_sparse - WeeklyEvidenceAnalyzer.Fact.AMBIGUOUS_ALLOCATION -> R.string.evidence_fact_ambiguous - } - - private fun metricText(label: String, value: String, highlighted: Boolean): CharSequence { - val result = SpannableString("$label\n$value") - if (highlighted) { - result.setSpan( - ForegroundColorSpan(getColor(R.color.cutline_mark)), - label.length + 1, - result.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - return result - } - - private fun statusText(label: String, mark: ActivityMark): CharSequence { - val result = SpannableString("$label\n${mark.symbol}") - mark.color?.let { color -> - result.setSpan( - ForegroundColorSpan(color), - label.length + 1, - result.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - return result - } - - private fun activityMark(value: Boolean?, observed: Boolean, dayClosed: Boolean): ActivityMark = - when { - value == true -> ActivityMark("\u2713", getColor(R.color.success_mark)) - !observed -> ActivityMark("-", null) - value == false && dayClosed -> ActivityMark("\u00d7", getColor(R.color.failure_mark)) - value == false -> ActivityMark("-", null) - else -> ActivityMark("?", null) - } - - private fun editableActivityMark(value: Boolean?): ActivityMark = when (value) { - true -> ActivityMark("\u2713", getColor(R.color.success_mark)) - false -> ActivityMark("\u00d7", getColor(R.color.failure_mark)) - null -> ActivityMark("-", null) - } - - private fun unknownActivityMark() = ActivityMark("?", getColor(R.color.warning_mark)) - - private fun showManualEditWarning(report: WeeklyReportBuilder.Report) { - AlertDialog.Builder(this) - .setTitle(R.string.manual_edit_warning_title) - .setMessage(R.string.manual_edit_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.confirm) { _, _ -> beginWeeklyEditing(report) } - .show() - } - - private fun beginWeeklyEditing(report: WeeklyReportBuilder.Report) { - editingPeriodStart = report.periodStart - editDraft.clear() - report.members.forEach { member -> - member.days.forEach { cell -> - editDraft[CellKey(member.uid, cell.gameDay)] = EditableCell( - uid = member.uid, - gameDay = cell.gameDay, - merit = cell.meritDelta?.toString().orEmpty(), - score = cell.scoreDelta?.toString().orEmpty(), - attempts = cell.attempts?.toString().orEmpty(), - attended = cell.attended, - dailyPatrol = cell.dailyPatrol, - existingOverride = cell.manualOverride, + icon = if (health.isComplete) R.drawable.ic_check_circle else R.drawable.ic_warning, + ) { WeeklyDialogs.showGuide(this@WeeklyReportActivity, report.isGunsmokeWeek) }.apply { + // The chevron carries the guide affordance so the counts keep the full row width. + contentDescription = "$contentDescription. ${getString(R.string.how_it_works)}" + }, lp()) + } + } + + // endregion + + // region Top bar and actions + + private fun updateTopBar() { + val current = model + val editing = current != null && edits.isEditing(current.report.periodStart) + editingBack.isEnabled = editing + screen.setNavigationEnabled(!editing) + val bar = screen.topBar + if (editing) { + bar.setNavigationIcon(R.drawable.ic_close, getString(R.string.cancel_editing)) + .setNavigation(::cancelEditing) + .setTitle( + getString(R.string.edit_weekly_table_title), + resources.getQuantityString(R.plurals.edited_cells, edits.size, edits.size), ) - } - } - requestRender() - } - - private fun saveWeeklyEdits(report: WeeklyReportBuilder.Report) { - if (editingPeriodStart != report.periodStart) { - cancelWeeklyEditing() + .setActions(listOf( + BarAction(R.drawable.ic_check, getString(R.string.save_weekly_edits), !edits.isEmpty() && !work.isBusy(SAVE)) { + saveEdits() + }, + )) return } - val overrides = runCatching { - editDraft.values.mapNotNull { draft -> - if (draft.existingOverride == null && !draft.isDirty) return@mapNotNull null - WeeklyCellOverride( - uid = draft.uid, - periodStart = report.periodStart, - gameDay = draft.gameDay, - meritDelta = if (draft.meritDirty) { - parseNonNegativeLong(draft.merit) - } else { - draft.existingOverride?.meritDelta + val hasMembers = current?.report?.members?.isNotEmpty() == true + val historyCount = current?.historyCount ?: 0 + bar.setNavigation(null) + .setTitle(getString(R.string.weekly_table)) + .setActions( + listOf( + BarAction(R.drawable.ic_share, getString(R.string.share_weekly_table), hasMembers) { + exports.share { + model?.let { WeeklyExportFlow.Source(it.report, it.displayedMembers, it.memberNotesByUid) } + } }, - scoreDelta = if (!report.isGunsmokeWeek) { - null - } else if (draft.scoreDirty) { - parseNonNegativeLong(draft.score) - } else { - draft.existingOverride?.scoreDelta + BarAction(R.drawable.ic_edit, getString(R.string.edit_weekly_table), hasMembers) { confirmEditing() }, + ), + overflow = listOf( + MenuEntry(getString(R.string.weekly_table_history_with_count, historyCount), historyCount > 0) { + current?.let { openHistory(it.report.periodStart) } }, - attempts = if (!report.isGunsmokeWeek) { - null - } else if (draft.attemptsDirty) { - parseAttempts(draft.attempts) - } else { - draft.existingOverride?.attempts + MenuEntry(getString(R.string.export_weekly_csv), hasMembers) { + current?.let { exports.exportWeek(it.report.periodStart) } }, - attended = if (draft.attendedDirty) { - draft.attended - } else { - draft.existingOverride?.attended + MenuEntry(getString(R.string.copy_weekly_csv), hasMembers) { + current?.let { exports.copyWeek(it.report) } }, - dailyPatrol = if (draft.dailyPatrolDirty) { - draft.dailyPatrol - } else { - draft.existingOverride?.dailyPatrol + MenuEntry(getString(R.string.export_all_weekly_tables)) { exports.exportAll() }, + MenuEntry(getString(R.string.edit_member_order)) { + startActivity(Intent(this, MemberOrderActivity::class.java)) }, - ).takeIf { it.hasAnyValue() } - } - }.getOrElse { - TransientMessage.show(this, R.string.invalid_weekly_edit, Toast.LENGTH_LONG) - return - } - repository.replaceWeeklyOverrides(report.periodStart.toEpochDay(), overrides) - cancelWeeklyEditing() - TransientMessage.show(this, R.string.weekly_edits_saved) - requestRender() - } - - private fun cancelWeeklyEditing() { - editingPeriodStart = null - editDraft.clear() + MenuEntry(getString(R.string.weekly_table_settings)) { + startActivity(Intent(this, WeeklySettingsActivity::class.java)) + }, + MenuEntry(getString(R.string.weekly_evidence_guide)) { + WeeklyDialogs.showGuide(this, current?.report?.isGunsmokeWeek == true) + }, + ), + ) } - private fun parseNonNegativeLong(value: String): Long? { - if (value.isBlank()) return null - return requireNotNull(value.toLongOrNull()).also { require(it >= 0) } + private fun openHistory(periodStart: LocalDate) { + startActivity( + Intent(this, WeeklyTableHistoryActivity::class.java) + .putExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START, periodStart.toEpochDay()), + ) } - private fun parseAttempts(value: String): Int? { - if (value.isBlank()) return null - return requireNotNull(value.toIntOrNull()).also { require(it in 0..3) } + private fun showLiveTable(periodStart: LocalDate) { + work.run(task = { repository.showLiveWeeklyReport(periodStart) }) { requestRender() } } private fun showDatePicker() { - DatePickerDialog( - this, - { _, year, month, day -> - reportState.selectDate(LocalDate.of(year, month + 1, day)) - requestRender() - }, - reportState.referenceDay.year, - reportState.referenceDay.monthValue - 1, - reportState.referenceDay.dayOfMonth, - ).show() - } - - private fun copyWeeklyCsv(report: WeeklyReportBuilder.Report) { - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText( - getString(R.string.weekly_csv_clipboard_label), - WeeklyReportCsv.format(report), + val day = reportState.referenceDay + Dialogs.showTracked( + DatePickerDialog( + this, + { _, year, month, dayOfMonth -> + reportState.selectDate(LocalDate.of(year, month + 1, dayOfMonth)) + requestRender() + }, + day.year, + day.monthValue - 1, + day.dayOfMonth, ), ) - TransientMessage.show(this, R.string.weekly_csv_copied) } - private fun addMembershipEvents( - report: WeeklyReportBuilder.Report, - events: List, - namesByUid: Map, - zoneId: ZoneId, - ) { - body.addView(TextView(this).apply { - text = getString(R.string.join_withdraw) - textSize = 21f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(16), 0, dp(4)) - }, matchWidth()) - val membershipEvents = MembershipEventPresentation.deduplicate(events) - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(membershipHeader(getString(R.string.join_label)), weightedCell()) - addView(membershipHeader(getString(R.string.withdraw_label)), weightedCell()) - }, matchWidth()) - - val datedEvents = membershipEvents - .mapNotNull { event -> - MembershipEventPresentation.calendarDate(event, zoneId)?.let { it to event } + private fun onCellTapped(member: WeeklyReportBuilder.MemberRow, cell: WeeklyReportBuilder.DayCell?) { + val current = model ?: return + val weeklyTable = table ?: return + val dayLabel = cell?.let { Labels.dayHeader(this, it.gameDay) } ?: getString(R.string.total) + val title = getString(R.string.weekly_cell_title, member.name, dayLabel) + if (edits.isEditing(current.report.periodStart)) { + if (cell == null) { + Feedback.show(this, R.string.total_not_editable) + return } - .groupBy({ it.first }, { it.second }) - report.days.forEach { day -> - val dayEvents = datedEvents[day].orEmpty() - if (dayEvents.isEmpty()) return@forEach - body.addView(TextView(this).apply { - text = day.format(DATE) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, dp(2)) - }, matchWidth()) - addMembershipEventRow(dayEvents, namesByUid, zoneId) - } - - val unknownEvents = membershipEvents.filter { - MembershipEventPresentation.calendarDate(it, zoneId) == null - } - if (unknownEvents.isNotEmpty()) { - body.addView(TextView(this).apply { - text = getString(R.string.unknown_date) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, dp(2)) - }, matchWidth()) - addMembershipEventRow(unknownEvents, namesByUid, zoneId) - } - } - - private fun addMembershipEventRow( - events: List, - namesByUid: Map, - zoneId: ZoneId, - ) { - val joins = events.filter { - it.type == MemberEventType.JOINED || it.type == MemberEventType.REJOINED + // The draft is being committed; edits made now would be cleared with it. + if (work.isBusy(SAVE)) return + val nextCell = member.days.getOrNull(member.days.indexOf(cell) + 1) + val next = nextCell?.let { { onCellTapped(member, it) } } + WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[member.uid, cell.gameDay], next) { edit -> + if (!edits.isEditing(current.report.periodStart) || !edits.put(edit)) return@showEdit + // Rows are addressed in the list the adapter currently shows, not the tapped model. + rowsAdapter.notifyMemberChanged(member.uid) + updateTopBar() + } + return } - val withdrawals = events.filter { - it.type == MemberEventType.LEFT || it.type == MemberEventType.REMOVED + val explanations = WeeklyEvidenceAnalyzer.Metric.entries.associateWith { metric -> + if (cell == null) { + WeeklyEvidenceAnalyzer.explainTotal(member, metric) + } else { + WeeklyEvidenceAnalyzer.explainDaily(cell, metric) + } } - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(membershipEventCell(joins, namesByUid, zoneId), weightedCell()) - addView(membershipEventCell(withdrawals, namesByUid, zoneId), weightedCell()) - }, matchWidth()) - } - - private fun membershipHeader(label: String) = TextView(this).apply { - text = label - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(4), dp(8), dp(4)) - } - - private fun weightedCell() = LinearLayout.LayoutParams(0, wrap(), 1f).apply { - marginEnd = dp(8) + WeeklyDialogs.showEvidence( + this, + title, + current.report.isGunsmokeWeek, + cell?.let { weeklyTable.daily(it) } ?: weeklyTable.total(member), + explanations, + ) } - private fun membershipEventCell( - events: List, - namesByUid: Map, - zoneId: ZoneId, - ) = TextView(this).apply { - text = events.joinToString("\n") { event -> - val eventName = namesByUid[event.uid].orEmpty().ifBlank { event.note } - val identity = eventName.takeIf { it.isNotBlank() } - ?.let { "$it (#${event.uid})" } - ?: "#${event.uid}" - MembershipEventPresentation.timePrefix(event, zoneId) - ?.let { "$it $identity" } - ?: identity + private fun confirmEditing() { + if (model == null) return + Dialogs.confirm(this, R.string.manual_edit_warning_title, R.string.manual_edit_warning, R.string.start_editing) { + // A resume may have re-rendered while the warning was open; edit what is shown now. + val current = model ?: return@confirm + edits.start(current.report.periodStart) + render(current) } - setPadding(0, dp(2), dp(8), dp(4)) - } - - private fun addNote(note: WeeklyNote) { - body.addView(Button(this).apply { - isAllCaps = false - text = note.text - setOnClickListener { confirmNoteDeletion(note) } - }, matchWidth()) - } - - private fun confirmNoteDeletion(note: WeeklyNote) { - AlertDialog.Builder(this) - .setTitle(R.string.delete_note) - .setMessage(R.string.delete_note_confirmation) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete) { _, _ -> - if (repository.deleteWeeklyNote(note.id)) { - TransientMessage.show(this, R.string.note_deleted) - requestRender() - } - } - .show() } - - private fun showWeeklyShareOptions(model: RenderModel) { - val includeNames = CheckBox(this).apply { - text = getString(R.string.share_include_member_names) - isChecked = true - } - val includeUids = CheckBox(this).apply { - text = getString(R.string.share_include_uids) - isChecked = false - } - val includeNotes = CheckBox(this).apply { - text = getString(R.string.share_include_private_notes) - isChecked = false - } - val options = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), 0) - addView(includeNames, matchWidth()) - addView(includeUids, matchWidth()) - addView(includeNotes, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.share_privacy_notice) - textSize = 13f - setPadding(0, dp(8), 0, 0) - }, matchWidth()) + private fun cancelEditing() { + if (edits.isEmpty()) { + finishEditing() + return } - fun privacy() = WeeklyShareProjection.Privacy( - includeMemberNames = includeNames.isChecked, - includeUids = includeUids.isChecked, - includePrivateNotes = includeNotes.isChecked, - ) - AlertDialog.Builder(this) - .setTitle(R.string.share_weekly_table) - .setView(options) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.save_png) { _, _ -> - generateWeeklyPng(model, privacy(), shareAfter = false) - } - .setPositiveButton(R.string.share_png) { _, _ -> - generateWeeklyPng(model, privacy(), shareAfter = true) - } - .show() - } - - private fun generateWeeklyPng( - model: RenderModel, - privacy: WeeklyShareProjection.Privacy, - shareAfter: Boolean, - ) { - TransientMessage.show(this, R.string.weekly_png_rendering) - workerExecutor.execute { - val result = runCatching { - val document = WeeklyShareProjection.build( - report = model.report, - displayedMembers = model.displayedMembers, - privateNotesByUid = model.memberNotesByUid, - privacy = privacy, - ) - writeWeeklyPng(document, model.report.periodStart) - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - result.fold( - onSuccess = { file -> - if (shareAfter) shareWeeklyPng(file) else saveWeeklyPng(file, model.report.periodStart) - }, - onFailure = { - TransientMessage.show( - this, - R.string.weekly_png_failed, - Toast.LENGTH_LONG, - ) - }, + Dialogs.confirm( + this, + R.string.discard_weekly_edits_title, + R.string.discard_weekly_edits, + R.string.discard, + destructive = true, + ) { finishEditing() } + } + + private fun finishEditing() { + edits.finish() + model?.let(::render) ?: updateTopBar() + } + + private fun saveEdits() { + val current = model ?: return + if (work.isBusy(SAVE)) return + val report = current.report + // Merge against the stored overrides, not the displayed table: a restored revision or a member + // missing from the roster would otherwise drop overrides the user never touched. + work.run(key = SAVE, task = { repository.listWeeklyOverrides(report.periodStart.toEpochDay()) }) { stored -> + stored.onFailure { Feedback.show(this, R.string.save_failed) } + stored.onSuccess { saveMerged(current, it) } + } + updateTopBar() + } + + private fun saveMerged(current: RenderModel, existing: List) { + val report = current.report + when (val merged = WeeklyOverrideEditing.merge(report.periodStart, report.isGunsmokeWeek, existing, edits.values)) { + is WeeklyOverrideEditing.Result.Invalid -> { + val name = current.namesByUid[merged.uid] ?: Labels.uid(this, merged.uid) + Feedback.show( + this, + getString(R.string.invalid_weekly_edit_cell, name, Labels.dayHeader(this, merged.gameDay)), + long = true, ) } - } - } - - private fun writeWeeklyPng( - document: WeeklyShareProjection.Document, - periodStart: LocalDate, - ): File { - val directory = WeeklyPngPendingState.directory(cacheDir).apply { mkdirs() } - require(directory.isDirectory) { "Unable to create weekly share cache" } - revokeAndDeleteOldWeeklyPngs(directory) - val target = WeeklyPngPendingState.newRenderTarget(cacheDir, periodStart) - val temporary = File.createTempFile(".weekly-", ".png", directory) - val bitmap = WeeklyReportPngRenderer.render(document) - try { - FileOutputStream(temporary).use { output -> - check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output)) { - "Unable to encode weekly PNG" + is WeeklyOverrideEditing.Result.Valid -> { + work.run( + key = SAVE, + task = { repository.replaceWeeklyOverrides(report.periodStart.toEpochDay(), merged.overrides) }, + ) { result -> + if (result.isSuccess) { + edits.finish() + Feedback.show(this, R.string.weekly_edits_saved) + requestRender() + } else { + Feedback.show(this, R.string.save_failed) + } + updateTopBar() } - output.fd.sync() + updateTopBar() } - check(temporary.renameTo(target)) { "Unable to publish weekly PNG" } - } finally { - bitmap.recycle() - temporary.delete() } - return target } - private fun revokeAndDeleteOldWeeklyPngs(directory: File) { - directory.listFiles().orEmpty() - .filter(java.io.File::isFile) - .forEach { stale -> - runCatching { - val uri = FileProvider.getUriForFile( - this, - packageName + ".fileprovider", - stale, - ) - revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - stale.delete() - } - } + // endregion - private fun shareWeeklyPng(file: File) { - val uri = FileProvider.getUriForFile( - this, - packageName + ".fileprovider", - file, - ) - val share = Intent(Intent.ACTION_SEND) - .setType("image/png") - .putExtra(Intent.EXTRA_STREAM, uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - share.clipData = ClipData.newUri(contentResolver, getString(R.string.share_weekly_table), uri) - startActivity(Intent.createChooser(share, getString(R.string.share_weekly_table))) - } + private inner class RowsAdapter : RecyclerView.Adapter() { + private var members = emptyList() - @Suppress("DEPRECATION") - private fun saveWeeklyPng(file: File, periodStart: LocalDate) { - pendingPng = file - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("image/png") - .putExtra(Intent.EXTRA_TITLE, WeeklyPngPendingState.exportName(periodStart)) - startActivityForResult(intent, REQUEST_EXPORT_WEEKLY_PNG) - } - @Deprecated("Uses the platform document picker without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_EXPORT_WEEKLY_PNG) { - val source = pendingPng - pendingPng = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || source == null) return - val exported = runCatching { - val output = TrustedExportDestination.openOutputStream(contentResolver, destination) - ?: error("Document provider did not open an output stream") - output.use { target -> - source.inputStream().use { input -> input.copyTo(target) } - } - }.isSuccess - source.delete() - TransientMessage.show( - this, - getString( - if (exported) R.string.weekly_png_saved else R.string.weekly_png_failed, - ), - Toast.LENGTH_SHORT, - ) - return + fun submit(next: List) { + members = next + notifyDataSetChanged() } - if (requestCode != REQUEST_EXPORT_WEEKLY) return + fun notifyMemberChanged(uid: Long) { + val position = members.indexOfFirst { it.uid == uid } + if (position >= 0) notifyItemChanged(position) + } - val content = pendingCsv - pendingCsv = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || content == null) return - val exported = runCatching { - val output = TrustedExportDestination.openOutputStream(contentResolver, destination) - ?: error("Document provider did not open an output stream") - output.writer(Charsets.UTF_8).use { it.write(content) } - }.isSuccess - TransientMessage.show( - this, - getString(if (exported) R.string.weekly_csv_exported else R.string.status_export_failed), - Toast.LENGTH_SHORT, - ) - } + override fun getItemCount() = members.size - @Suppress("DEPRECATION") - private fun exportWeeklyCsv(report: WeeklyReportBuilder.Report) { - pendingCsv = WeeklyReportCsv.format(report) - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra( - Intent.EXTRA_TITLE, - "GF2logger-week-${report.periodStart.format(FILE_DATE)}.csv", - ) - startActivityForResult(intent, REQUEST_EXPORT_WEEKLY) - } + override fun getItemViewType(position: Int) = if (table?.isGunsmokeWeek == true) GUNSMOKE_ROW else STANDARD_ROW - private fun exportAllWeeklyTables() { - workerExecutor.execute { - val content = runCatching { - repository.listAllWeeklyReports(GameTimeZonePreferences.get(this)) - .takeIf(List<*>::isNotEmpty) - ?.let(WeeklyReportCsv::formatAll) - }.getOrNull() - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (content == null) { - TransientMessage.show(this, R.string.no_weekly_tables_to_export) - return@runOnUiThread - } - pendingCsv = content - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_TITLE, "mobileGF2logger-all-weekly-tables.csv") - @Suppress("DEPRECATION") - startActivityForResult(intent, REQUEST_EXPORT_WEEKLY) - } - } - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RowHolder = + RowHolder(requireNotNull(tables[viewType == GUNSMOKE_ROW]).MemberRow()) - private fun addNoteEditor(report: WeeklyReportBuilder.Report) { - val day = Spinner(this).apply { - adapter = ArrayAdapter( - this@WeeklyReportActivity, - android.R.layout.simple_spinner_dropdown_item, - report.days.map { it.format(DATE) }, - ) + override fun onBindViewHolder(holder: RowHolder, position: Int) { + val member = members[position] + val current = model ?: return + val dayEdits = edits.forMember(member.uid) + val rank = current.scoreRanks[member.uid].takeIf { current.report.isGunsmokeWeek } + holder.row.bind(member, rank, dayEdits) { cell -> onCellTapped(member, cell) } } - val note = EditText(this).apply { - hint = getString(R.string.note_hint) - setSingleLine(true) - } - body.addView(day, matchWidth()) - body.addView(note, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.add_note) - usePrimaryActionStyle() - setOnClickListener { - val text = note.text.toString().trim() - if (text.isBlank()) return@setOnClickListener - val gameDay = report.days[day.selectedItemPosition] - runCatching { - repository.addWeeklyNote( - report.periodStart.toEpochDay(), - gameDay.toEpochDay(), - text, - ) - }.fold( - onSuccess = { - TransientMessage.show( - this@WeeklyReportActivity, - getString(R.string.saved), - Toast.LENGTH_SHORT, - ) - requestRender() - }, - onFailure = { error -> - TransientMessage.show( - this@WeeklyReportActivity, - getString( - if (error is WeeklyNoteLimitException) { - R.string.weekly_note_limit_reached - } else { - R.string.save_failed - }, - ), - Toast.LENGTH_SHORT, - ) - }, - ) - } - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun matchParent() = ViewGroup.LayoutParams.MATCH_PARENT - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun wrapWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private class RowHolder(val row: WeeklyTable.MemberRow) : RecyclerView.ViewHolder(row.view) private data class RenderModel( val zone: ZoneId, @@ -1802,59 +610,14 @@ class WeeklyReportActivity : LocalizedActivity() { val memberNotesByUid: Map, val historyCount: Int, val restoredHistory: Boolean, + val currentWeekStart: LocalDate, ) - private data class ActivityMark(val symbol: String, val color: Int?) - - private data class CellKey(val uid: Long, val gameDay: LocalDate) - - private data class EditableCell( - val uid: Long, - val gameDay: LocalDate, - var merit: String, - var score: String, - var attempts: String, - var attended: Boolean?, - var dailyPatrol: Boolean?, - val existingOverride: WeeklyCellOverride?, - var meritDirty: Boolean = false, - var scoreDirty: Boolean = false, - var attemptsDirty: Boolean = false, - var attendedDirty: Boolean = false, - var dailyPatrolDirty: Boolean = false, - ) { - val isDirty: Boolean - get() = meritDirty || scoreDirty || attemptsDirty || attendedDirty || dailyPatrolDirty - } - - private fun WeeklyCellOverride.hasAnyValue(): Boolean = - meritDelta != null || scoreDelta != null || attempts != null || - attended != null || dailyPatrol != null - - private fun gridBackground() = GradientDrawable().apply { - setColor(Color.TRANSPARENT) - setStroke(1, getColor(R.color.outline_strong)) - } - - private fun editableFieldBackground() = GradientDrawable().apply { - setColor(getColor(R.color.surface_variant)) - setStroke(dp(1), getColor(R.color.primary)) - cornerRadius = dp(8).toFloat() - } - - companion object { - private val DATE = DateTimeFormatter.ofPattern("yy/MM/dd") - private val DAY = DateTimeFormatter.ofPattern("MM/dd") - private val FILE_DATE = DateTimeFormatter.BASIC_ISO_DATE - private const val REQUEST_EXPORT_WEEKLY = 201 - private const val STATE_REFERENCE_DAY = "weekly.reference_day" - private const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" - private const val HEADER_HEIGHT = 40 - private const val REQUEST_EXPORT_WEEKLY_PNG = 202 - private const val METRIC_HEIGHT = 36 - private const val RANK_WIDTH = 42 - private const val MEMBER_WIDTH = 120 - private const val DAILY_WIDTH = 128 - private const val MAX_VISIBLE_TABLE_ROWS = 6 + private companion object { + const val RENDER = "render" + const val STANDARD_ROW = 0 + const val GUNSMOKE_ROW = 1 + const val SAVE = "save" + const val STATE_REFERENCE_DAY = "weekly.reference_day" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt index 6491759..bf00807 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt @@ -1,5 +1,6 @@ package dev.gf2log.app +import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -9,9 +10,12 @@ import dev.gf2log.app.management.WeeklyShareProjection import kotlin.math.min import kotlin.math.sqrt -/** Renders a bounded, privacy-filtered weekly share document without reading UI views. */ +/** + * Renders a bounded, privacy-filtered weekly share document without reading UI views. The image + * always uses the light paper palette so shared tables stay legible in any chat app. + */ object WeeklyReportPngRenderer { - fun render(document: WeeklyShareProjection.Document): Bitmap { + fun render(context: Context, document: WeeklyShareProjection.Document): Bitmap { require(document.headers.size in 3..11) require(document.rows.size <= MAX_ROWS) val memberWidth = 240 @@ -38,26 +42,31 @@ object WeeklyReportPngRenderer { val height = maxOf(1, (naturalHeight * scale).toInt()) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) - canvas.drawColor(Color.WHITE) + val paints = CellPaints() + canvas.drawColor(PAPER) canvas.scale(scale, scale) val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(27, 47, 78) + color = ACCENT_INK textSize = 30f typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) } val subtitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(70, 78, 91) + color = MUTED_INK textSize = 18f } val health = document.evidenceHealth canvas.drawText(document.title, PADDING.toFloat(), (PADDING + 32).toFloat(), titlePaint) canvas.drawText(document.subtitle, PADDING.toFloat(), (PADDING + 58).toFloat(), subtitlePaint) canvas.drawText( - "Evidence: " + health.observedDays + "/" + health.totalDays + - " days \u2022 exact " + health.exactMetrics + - " \u2022 minimum " + health.lowerBoundMetrics + - " \u2022 unknown " + health.unknownMetrics, + context.getString( + R.string.png_evidence_summary, + health.observedDays, + health.totalDays, + health.exactMetrics, + health.lowerBoundMetrics, + health.unknownMetrics, + ), PADDING.toFloat(), (PADDING + 84).toFloat(), subtitlePaint, @@ -65,7 +74,7 @@ object WeeklyReportPngRenderer { var top = PADDING + TITLE_HEIGHT var left = PADDING - drawCell(canvas, document.headers.first(), left, top, memberWidth, HEADER_HEIGHT, header = true) + drawCell(canvas, document.headers.first(), left, top, memberWidth, HEADER_HEIGHT, paints, header = true) left += memberWidth document.headers.drop(1).forEachIndexed { index, value -> val isLast = index == document.headers.lastIndex - 1 @@ -74,23 +83,23 @@ object WeeklyReportPngRenderer { index == days -> totalWidth else -> dayWidth } - drawCell(canvas, value, left, top, cellWidth, HEADER_HEIGHT, header = true) + drawCell(canvas, value, left, top, cellWidth, HEADER_HEIGHT, paints, header = true) left += cellWidth } top += HEADER_HEIGHT document.rows.forEach { row -> left = PADDING - drawCell(canvas, row.member, left, top, memberWidth, rowHeight) + drawCell(canvas, row.member, left, top, memberWidth, rowHeight, paints) left += memberWidth row.dailyCells.forEach { value -> - drawCell(canvas, value, left, top, dayWidth, rowHeight) + drawCell(canvas, value, left, top, dayWidth, rowHeight, paints) left += dayWidth } - drawCell(canvas, row.total, left, top, totalWidth, rowHeight) + drawCell(canvas, row.total, left, top, totalWidth, rowHeight, paints) left += totalWidth if (document.includeNotes) { - drawCell(canvas, row.privateNote.orEmpty(), left, top, notesWidth, rowHeight) + drawCell(canvas, row.privateNote.orEmpty(), left, top, notesWidth, rowHeight, paints) } top += rowHeight } @@ -104,27 +113,15 @@ object WeeklyReportPngRenderer { top: Int, width: Int, height: Int, + paints: CellPaints, header: Boolean = false, ) { - val fill = Paint().apply { - color = if (header) Color.rgb(218, 228, 244) else Color.WHITE - style = Paint.Style.FILL - } - val border = Paint().apply { - color = Color.rgb(122, 132, 146) - style = Paint.Style.STROKE - strokeWidth = 1f - } - canvas.drawRect(left.toFloat(), top.toFloat(), (left + width).toFloat(), (top + height).toFloat(), fill) - canvas.drawRect(left.toFloat(), top.toFloat(), (left + width).toFloat(), (top + height).toFloat(), border) - val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(28, 32, 38) - textSize = if (header) 18f else 16f - typeface = Typeface.create( - Typeface.DEFAULT, - if (header) Typeface.BOLD else Typeface.NORMAL, - ) - } + val textPaint = if (header) paints.headerText else paints.bodyText + paints.fill.color = if (header) HEADER else SURFACE + val right = (left + width).toFloat() + val bottom = (top + height).toFloat() + canvas.drawRect(left.toFloat(), top.toFloat(), right, bottom, paints.fill) + canvas.drawRect(left.toFloat(), top.toFloat(), right, bottom, paints.border) val lines = wrap(value, textPaint, width - CELL_PADDING * 2) .take(MAX_CELL_LINES) val lineHeight = textPaint.fontSpacing @@ -137,6 +134,24 @@ object WeeklyReportPngRenderer { } } + /** Paints reused for every cell of one render; each render owns its own set. */ + private class CellPaints { + val fill = Paint().apply { style = Paint.Style.FILL } + val border = Paint().apply { + color = OUTLINE + style = Paint.Style.STROKE + strokeWidth = 1f + } + val headerText = text(18f, Typeface.BOLD) + val bodyText = text(16f, Typeface.NORMAL) + + private fun text(size: Float, style: Int) = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = INK + textSize = size + typeface = Typeface.create(Typeface.DEFAULT, style) + } + } + private fun wrap(value: String, paint: Paint, maxWidth: Int): List = buildList { value.lineSequence().forEach { source -> var remaining = source.trim() @@ -157,6 +172,13 @@ object WeeklyReportPngRenderer { } } + private val PAPER = Color.rgb(0xF7, 0xF4, 0xEF) + private val SURFACE = Color.rgb(0xFF, 0xFC, 0xF8) + private val HEADER = Color.rgb(0xEC, 0xE7, 0xDF) + private val OUTLINE = Color.rgb(0xC9, 0xC2, 0xB8) + private val INK = Color.rgb(0x25, 0x23, 0x1F) + private val MUTED_INK = Color.rgb(0x6C, 0x66, 0x5F) + private val ACCENT_INK = Color.rgb(0x7D, 0x3F, 0x1E) private const val PADDING = 28 private const val TITLE_HEIGHT = 104 private const val HEADER_HEIGHT = 52 diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 6822577..9fbedd9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -1,191 +1,167 @@ package dev.gf2log.app -import android.graphics.Typeface import android.os.Bundle import android.text.InputType -import android.view.Gravity -import android.view.ViewGroup -import android.widget.Button -import android.widget.CheckBox +import android.view.View import android.widget.EditText import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import androidx.activity.OnBackPressedCallback import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField -class WeeklySettingsActivity : LocalizedActivity() { - private lateinit var dailyMerit: ThresholdInput - private lateinit var dailyScore: ThresholdInput - private lateinit var dailyAttempts: ThresholdInput - private lateinit var weeklyMerit: ThresholdInput - private lateinit var weeklyScore: ThresholdInput - private lateinit var weeklyAttempts: ThresholdInput - private lateinit var loginDays: ThresholdInput - private lateinit var patrolDays: ThresholdInput +/** Per-Platoon cutlines that highlight weekly cells which are, or may remain, below a target. */ +class WeeklySettingsActivity : BaseActivity() { + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var preferences: WeeklyCutlinePreferences + private lateinit var initial: WeeklyCutlines + private val inputs = mutableListOf() + private var restoredEnabled: BooleanArray? = null + private val unsavedBack = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = leave() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - title = getString(R.string.weekly_table_settings) - setContentView(buildContentView(WeeklyCutlinePreferences(this).read())) + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope ?: run { + finish() + return + } + preferences = WeeklyCutlinePreferences(this, scope.storageId) + initial = preferences.read() + restoredEnabled = savedInstanceState?.getBooleanArray(STATE_ENABLED) + onBackPressedDispatcher.addCallback(this, unsavedBack) + val screen = Screen(this) + screen.topBar + .setNavigation(::leave) + .setTitle(getString(R.string.weekly_table_settings)) + .setActions(listOf(BarAction(R.drawable.ic_check, getString(R.string.save_settings)) { save() })) + val content = column { + addView(text(getString(R.string.cutline_description), TextRole.SECONDARY), lp(top = 4)) + addView(sectionHeader(getString(R.string.daily_cutlines)), lp()) + addView(panel(padding = 8) { + threshold(R.id.cutline_daily_merit, R.string.merit_cutline, initial.dailyMerit, null) + threshold(R.id.cutline_daily_score, R.string.gunsmoke_score_cutline, initial.dailyGunsmokeScore, null) + threshold(R.id.cutline_daily_attempts, R.string.gunsmoke_attempt_cutline, initial.dailyGunsmokeAttempts?.toLong(), DAILY_ATTEMPTS) + }, lp()) + addView(sectionHeader(getString(R.string.weekly_cutlines)), lp()) + addView(panel(padding = 8) { + threshold(R.id.cutline_weekly_merit, R.string.merit_cutline, initial.weeklyMerit, null) + threshold(R.id.cutline_weekly_score, R.string.gunsmoke_score_cutline, initial.weeklyGunsmokeScore, null) + threshold(R.id.cutline_weekly_attempts, R.string.gunsmoke_attempt_cutline, initial.weeklyGunsmokeAttempts?.toLong(), WEEKLY_ATTEMPTS) + threshold(R.id.cutline_login_days, R.string.weekly_login_cutline, initial.weeklyLoginDays?.toLong(), WEEK_DAYS) + threshold(R.id.cutline_patrol_days, R.string.weekly_patrol_cutline, initial.weeklyPatrolDays?.toLong(), WEEK_DAYS) + }, lp()) + addView(button(getString(R.string.save_settings), Controls.Role.PRIMARY) { save() }, lp(top = 16)) + } + screen.setBody(Screen.scrollingBody(this, content)) } - private fun buildContentView(current: WeeklyCutlines): ScrollView { - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - - addView(TextView(context).apply { - text = getString(R.string.weekly_table_settings) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.cutline_description) - setPadding(0, dp(6), 0, dp(12)) - }, matchWidth()) - - addSectionHeading(R.string.daily_cutlines) - dailyMerit = thresholdInput(R.string.merit_cutline, current.dailyMerit, null) - dailyScore = thresholdInput( - R.string.gunsmoke_score_cutline, - current.dailyGunsmokeScore, - null, - ) - dailyAttempts = thresholdInput( - R.string.gunsmoke_attempt_cutline, - current.dailyGunsmokeAttempts?.toLong(), - 3, - ) - - addSectionHeading(R.string.weekly_cutlines) - weeklyMerit = thresholdInput(R.string.merit_cutline, current.weeklyMerit, null) - weeklyScore = thresholdInput( - R.string.gunsmoke_score_cutline, - current.weeklyGunsmokeScore, - null, - ) - weeklyAttempts = thresholdInput( - R.string.gunsmoke_attempt_cutline, - current.weeklyGunsmokeAttempts?.toLong(), - 21, - ) - loginDays = thresholdInput( - R.string.weekly_login_cutline, - current.weeklyLoginDays?.toLong(), - 7, - ) - patrolDays = thresholdInput( - R.string.weekly_patrol_cutline, - current.weeklyPatrolDays?.toLong(), - 7, - ) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() + } - addView(Button(context).apply { - text = getString(R.string.save_settings) - usePrimaryActionStyle() - setOnClickListener { save() } - }, matchWidth()) + private fun LinearLayout.threshold(id: Int, label: Int, value: Long?, maximum: Int?) { + val input = textField(getString(R.string.cutline_value), id = id, inputType = InputType.TYPE_CLASS_NUMBER).apply { + setText(value?.toString().orEmpty()) } - return ScrollView(this).apply { addView(container, matchWidth()) } + val supporting = maximum?.let { getString(R.string.cutline_range, it) } + val field = LabeledField(context, getString(R.string.cutline_value), input, supporting) + val threshold = Threshold(input, field, maximum, restoredEnabled?.getOrNull(inputs.size) ?: (value != null)) + field.visibility = if (threshold.enabled) View.VISIBLE else View.GONE + addView(switchRow(getString(label), supporting, threshold.enabled) { checked -> + threshold.enabled = checked + field.visibility = if (checked) View.VISIBLE else View.GONE + if (checked) input.requestFocus() + }, lp()) + addView(field, lp(start = 4, end = 4, bottom = 8)) + inputs += threshold } - private fun LinearLayout.addSectionHeading(label: Int) { - addView(TextView(context).apply { - text = getString(label) - textSize = 20f - setPadding(0, dp(12), 0, dp(4)) - }, matchWidth()) + override fun onSaveInstanceState(outState: Bundle) { + outState.putBooleanArray(STATE_ENABLED, inputs.map(Threshold::enabled).toBooleanArray()) + super.onSaveInstanceState(outState) } - private fun LinearLayout.thresholdInput( - label: Int, - value: Long?, - maximum: Int?, - ): ThresholdInput { - val input = ThresholdInput( - checkBox = CheckBox(context).apply { - text = getString(label) - isChecked = value != null - gravity = Gravity.CENTER_VERTICAL - }, - value = EditText(context).apply { - inputType = InputType.TYPE_CLASS_NUMBER - setSingleLine(true) - hint = getString(R.string.cutline_value) - setText(value?.toString().orEmpty()) - isEnabled = value != null - gravity = Gravity.CENTER_VERTICAL - }, - maximum = maximum, - ) - input.checkBox.setOnCheckedChangeListener { _, checked -> - input.value.isEnabled = checked + private fun readValues(): List? { + var valid = true + val values = inputs.map { threshold -> + if (!threshold.enabled) { + threshold.field.showError(null) + return@map null + } + val parsed = threshold.input.text.toString().trim().toLongOrNull() + val ok = parsed != null && parsed >= 0 && threshold.maximum?.let { parsed <= it } != false + threshold.field.showError( + if (ok) null else threshold.maximum?.let { getString(R.string.cutline_range_error, it) } + ?: getString(R.string.cutline_number_error), + ) + if (!ok) valid = false + parsed } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(56) - addView(input.checkBox, LinearLayout.LayoutParams(0, dp(48), 2f)) - addView(input.value, LinearLayout.LayoutParams(0, dp(48), 1f)) - }, matchWidth()) - return input + return values.takeIf { valid } } + private fun current(values: List) = WeeklyCutlines( + dailyMerit = values[0], + dailyGunsmokeScore = values[1], + dailyGunsmokeAttempts = values[2]?.toInt(), + weeklyMerit = values[3], + weeklyGunsmokeScore = values[4], + weeklyGunsmokeAttempts = values[5]?.toInt(), + weeklyLoginDays = values[6]?.toInt(), + weeklyPatrolDays = values[7]?.toInt(), + ) + private fun save() { - val values = listOf( - dailyMerit, - dailyScore, - dailyAttempts, - weeklyMerit, - weeklyScore, - weeklyAttempts, - loginDays, - patrolDays, - ).map { runCatching { it.read() }.getOrNull() } - if (values.any { it == INVALID }) { - TransientMessage.show(this, R.string.invalid_cutline) - return - } - WeeklyCutlinePreferences(this).write( - WeeklyCutlines( - dailyMerit = values[0], - dailyGunsmokeScore = values[1], - dailyGunsmokeAttempts = values[2]?.toInt(), - weeklyMerit = values[3], - weeklyGunsmokeScore = values[4], - weeklyGunsmokeAttempts = values[5]?.toInt(), - weeklyLoginDays = values[6]?.toInt(), - weeklyPatrolDays = values[7]?.toInt(), - ), - ) - TransientMessage.show(this, R.string.saved) + val values = readValues() ?: return + preferences.write(current(values)) + Feedback.show(this, R.string.saved) finish() } - private fun ThresholdInput.read(): Long? { - if (!checkBox.isChecked) return null - val parsed = value.text.toString().toLongOrNull() ?: return INVALID - if (parsed < 0 || maximum?.let { parsed > it } == true) return INVALID - return parsed + private fun leave() { + val values = readValues() + if (values != null && current(values) == initial) { + finish() + return + } + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.unsaved_changes_title) + .setMessage(R.string.unsaved_changes_message) + .setNegativeButton(R.string.discard) { _, _ -> finish() } + .setPositiveButton(R.string.save_settings) { _, _ -> save() }, + ) } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private data class ThresholdInput( - val checkBox: CheckBox, - val value: EditText, + private class Threshold( + val input: EditText, + val field: LabeledField, val maximum: Int?, + var enabled: Boolean, ) private companion object { - const val INVALID = Long.MIN_VALUE + const val DAILY_ATTEMPTS = 3 + const val WEEKLY_ATTEMPTS = 21 + const val WEEK_DAYS = 7 + const val STATE_ENABLED = "cutlines.enabled" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt new file mode 100644 index 0000000..a43b6c5 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -0,0 +1,437 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import androidx.annotation.ColorRes +import dev.gf2log.app.management.DailyEvidence +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.management.PlatoonPeriods +import dev.gf2log.app.management.WeeklyMetricPresentation +import dev.gf2log.app.management.WeeklyOverrideEditing +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text +import java.time.Instant +import java.time.ZoneId +import java.util.WeakHashMap + +/** Keeps every row's horizontal scroll position identical so the table scrolls as one grid. */ +internal class HorizontalSync { + private val views = WeakHashMap() + var offset = 0 + private set + + fun register(view: SyncedScrollView) { + views[view] = Unit + } + + private var placedFor: Any? = null + + fun publish(source: SyncedScrollView, x: Int) { + offset = x + views.keys.forEach { if (it !== source) it.syncTo(x) } + } + + /** Sets the start position once per [key], so re-renders of the same week keep the user's scroll. */ + fun placeOnce(key: Any, x: Int) { + if (placedFor == key) return + placedFor = key + offset = x + views.keys.forEach { it.syncTo(x) } + } +} + +internal class SyncedScrollView(context: Context, private val sync: HorizontalSync) : HorizontalScrollView(context) { + private var syncing = false + + init { + isHorizontalScrollBarEnabled = false + overScrollMode = OVER_SCROLL_NEVER + sync.register(this) + } + + override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { + super.onScrollChanged(l, t, oldl, oldt) + if (!syncing) sync.publish(this, l) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + syncTo(sync.offset) + } + + fun syncTo(x: Int) { + if (scrollX == x) return + syncing = true + scrollTo(x, 0) + syncing = false + } +} + +/** Display-only projection of one weekly cell. */ +internal data class CellDisplay( + val merit: String, + val meritWarn: Boolean, + val score: String?, + val scoreWarn: Boolean, + val attempts: String?, + val attemptsWarn: Boolean, + val login: Mark, + val patrol: Mark, + val edited: Boolean, +) { + data class Mark(val symbol: String, @ColorRes val color: Int, val spoken: Int? = null) +} + +/** + * Builds the weekly grid rows: a frozen member column followed by seven daily cells and the + * weekly total. Values keep the evidence notation from the domain (`≥` minimum, `?` unknown). + */ +internal class WeeklyTable( + private val context: Context, + val isGunsmokeWeek: Boolean, + var zone: ZoneId, + var cutlines: WeeklyCutlines, + val sync: HorizontalSync = HorizontalSync(), +) { + /** Reference instant for open-day marks; refreshed with each render. */ + var now: Instant = Instant.now() + /** Heights follow the user's font size; widths grow more gently so a week still fits. */ + private val fontScale = context.resources.configuration.fontScale.coerceAtLeast(1f) + private fun height(dp: Int) = (context.dp(dp) * fontScale).toInt() + private fun width(dp: Int) = (context.dp(dp) * fontScale.coerceAtMost(1.5f)).toInt() + + val rowHeight = height(if (isGunsmokeWeek) GUNSMOKE_ROW_HEIGHT else STANDARD_ROW_HEIGHT) + + fun headerRow(report: WeeklyReportBuilder.Report): View { + // Open the current week at yesterday so today's column is on screen without swiping. + val todayIndex = report.days.indexOf(PlatoonPeriods.gameDay(now, zone)) + sync.placeOnce(report.days.first(), (width(DAY_WIDTH) + hairline) * (todayIndex - 1).coerceAtLeast(0)) + return headerGridRow(report) + } + + private fun headerGridRow(report: WeeklyReportBuilder.Report): View = gridRow( + frozen = headerCell(context.getString(R.string.member), null, MEMBER_WIDTH, Gravity.START or Gravity.CENTER_VERTICAL), + cells = report.days.map { day -> + headerCell( + day.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, context.resources.configuration.locales[0]), + "${day.monthValue}/${day.dayOfMonth}", + DAY_WIDTH, + Gravity.CENTER, + today = day == PlatoonPeriods.gameDay(now, zone), + ) + } + headerCell(context.getString(R.string.total), null, TOTAL_WIDTH, Gravity.CENTER), + height = height(HEADER_HEIGHT), + ) + + /** One reusable member row; [bind] refreshes it in place for RecyclerView recycling. */ + inner class MemberRow { + private val name = context.text(null, TextRole.BODY) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + } + private val detail = context.text(null, TextRole.CAPTION) { maxLines = 1 } + private val rankChip = GradientDrawable().apply { cornerRadius = context.dpf(8f) } + private val rank = context.text(null, TextRole.LABEL) { + gravity = Gravity.CENTER + background = rankChip + minWidth = context.dp(24) + setPadding(context.dp(4), 0, context.dp(4), 0) + visibility = if (isGunsmokeWeek) View.VISIBLE else View.GONE + } + private val frozen = context.row { + setPadding(context.dp(10), 0, context.dp(6), 0) + background = ColorDrawable(context.getColor(R.color.table_cell)) + addView(rank, context.lp(WRAP, WRAP, end = 6)) + addView(context.column { + addView(name, context.lp()) + addView(detail, context.lp()) + }, context.lp(width = 0, weight = 1f)) + } + private val cells = List(DAYS + 1) { MetricCell(if (it == DAYS) TOTAL_WIDTH else DAY_WIDTH, total = it == DAYS) } + val view: View = gridRow(frozen, cells.map { it.view }, rowHeight) + + fun bind( + member: WeeklyReportBuilder.MemberRow, + rankValue: Int?, + edits: Map, + onCell: (WeeklyReportBuilder.DayCell?) -> Unit, + ) { + name.text = member.name + detail.text = Labels.uid(context, member.uid) + rank.text = rankValue?.toString().orEmpty() + // Only the podium carries the accent; the rest of the ranking stays quiet. + val podium = rankValue != null && rankValue <= PODIUM + rankChip.setColor(context.getColor(if (podium) R.color.accent_surface else R.color.surface_variant)) + rank.setTextColor(context.getColor(if (podium) R.color.accent_text else R.color.text_secondary)) + frozen.contentDescription = listOfNotNull( + rankValue?.let { context.getString(R.string.rank_description, it) }, + member.name, + Labels.uid(context, member.uid), + ).joinToString(". ") + frozen.isFocusable = true + member.days.forEachIndexed { index, cell -> + cells.getOrNull(index)?.bind( + display = daily(cell, edits[cell.gameDay]), + description = context.getString(R.string.weekly_cell_description, member.name, Labels.dayHeader(context, cell.gameDay)), + ) { onCell(cell) } + } + cells.last().bind( + display = total(member), + description = context.getString(R.string.weekly_cell_description, member.name, context.getString(R.string.total)), + ) { onCell(null) } + } + } + + private inner class MetricCell(widthDp: Int, private val total: Boolean) { + private val fill = ColorDrawable() + private val merit = context.text(null, TextRole.SUBTITLE) { + textSize = 16f + gravity = Gravity.CENTER + maxLines = 1 + } + private val gunsmoke = context.text(null, TextRole.CAPTION) { + gravity = Gravity.CENTER + maxLines = 1 + visibility = if (isGunsmokeWeek) View.VISIBLE else View.GONE + } + private val activity = context.text(null, TextRole.CAPTION) { + gravity = Gravity.CENTER + maxLines = 1 + } + val view: LinearLayout = context.column { + gravity = Gravity.CENTER + setPadding(context.dp(4), context.dp(4), context.dp(4), context.dp(4)) + addView(merit, context.lp()) + addView(gunsmoke, context.lp()) + addView(activity, context.lp()) + layoutParams = LinearLayout.LayoutParams(width(widthDp), rowHeight) + background = RippleDrawable(ColorStateList.valueOf(context.getColor(R.color.control_ripple)), fill, null) + isClickable = true + isFocusable = true + } + + fun bind(display: CellDisplay, description: String, onClick: () -> Unit) { + merit.text = display.merit + // Unknown values recede so confirmed numbers and cutline warnings stand out. + merit.setTextColor( + context.getColor( + when { + display.meritWarn -> R.color.cutline_mark + display.merit == "?" -> R.color.text_secondary + else -> R.color.text_primary + }, + ), + ) + merit.paintFlags = if (display.meritWarn) merit.paintFlags or android.graphics.Paint.UNDERLINE_TEXT_FLAG + else merit.paintFlags and android.graphics.Paint.UNDERLINE_TEXT_FLAG.inv() + if (isGunsmokeWeek) { + gunsmoke.text = "${display.score ?: "-"} · ${display.attempts ?: "-"}" + gunsmoke.setTextColor( + context.getColor(if (display.scoreWarn || display.attemptsWarn) R.color.cutline_mark else R.color.text_secondary), + ) + } + activity.text = android.text.SpannableStringBuilder().apply { + appendMark(context.getString(R.string.login_initial), display.login) + append(" ") + appendMark(context.getString(R.string.patrol_initial), display.patrol) + } + // Recolor the cell's own fill in place; binding allocates no drawables. + fill.color = context.getColor( + when { + display.edited -> R.color.accent_surface + display.meritWarn || display.scoreWarn || display.attemptsWarn -> R.color.warning_surface + total -> R.color.table_header + else -> R.color.table_cell + }, + ) + view.contentDescription = buildString { + append(description) + append(". ") + append(context.getString(R.string.merit_short)).append(' ').append(spoken(display.merit)) + if (display.meritWarn) append(", ").append(context.getString(R.string.below_cutline)) + if (isGunsmokeWeek) { + append(". ").append(context.getString(R.string.point_short)).append(' ').append(spoken(display.score ?: "-")) + append(". ").append(context.getString(R.string.attempt_short)).append(' ').append(spoken(display.attempts ?: "-")) + } + append(". ").append(context.getString(R.string.login_short)).append(' ').append(spoken(display.login)) + append(". ").append(context.getString(R.string.patrol_short)).append(' ').append(spoken(display.patrol)) + if (display.edited) append(". ").append(context.getString(R.string.edited)) + } + view.setOnClickListener { onClick() } + } + + private fun android.text.SpannableStringBuilder.appendMark(label: String, mark: CellDisplay.Mark) { + append(label) + val start = length + append(mark.symbol) + setSpan( + android.text.style.ForegroundColorSpan(context.getColor(mark.color)), + start, + length, + android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + private fun spoken(mark: CellDisplay.Mark): String = mark.spoken?.let(context::getString) ?: spoken(mark.symbol) + + private fun spoken(value: String): String = when { + value == "?" -> context.getString(R.string.evidence_unknown_short) + value.startsWith("≥") -> context.getString(R.string.at_least, value.drop(1)) + else -> value + } + + fun daily(cell: WeeklyReportBuilder.DayCell, edit: WeeklyOverrideEditing.CellEdit? = null): CellDisplay { + val dayClosed = !PlatoonPeriods.periodStartInstant(cell.gameDay.plusDays(1), zone).isAfter(now) + val base = CellDisplay( + merit = WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty), + meritWarn = WeeklyMetricPresentation.warnsBelowCutline(cell.meritDelta, cell.meritCertainty, cutlines::belowDailyMerit), + score = if (isGunsmokeWeek) WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty) else null, + scoreWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(cell.scoreDelta, cell.scoreCertainty, cutlines::belowDailyScore), + attempts = if (isGunsmokeWeek) { + WeeklyMetricPresentation.format( + cell.attempts, + cell.attemptsCertainty, + missing = if (cell.manualOverride != null) "-" else "?", + ) + } else { + null + }, + attemptsWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(cell.attempts, cell.attemptsCertainty, cutlines::belowDailyAttempts), + login = if (cell.attended == null && cell.evidence != DailyEvidence.MANUAL) { + UNKNOWN_MARK + } else { + activityMark(cell.attended, cell.observed, dayClosed) + }, + patrol = if (cell.dailyPatrol == null && cell.evidence != DailyEvidence.MANUAL) { + UNKNOWN_MARK + } else { + activityMark(cell.dailyPatrol, cell.observed, dayClosed) + }, + edited = false, + ) + if (edit == null || edit.isEmpty) return base + return base.copy( + merit = edit.merit?.let { it.trim().ifEmpty { "-" } } ?: base.merit, + meritWarn = edit.merit == null && base.meritWarn, + score = edit.score?.let { it.trim().ifEmpty { "-" } } ?: base.score, + scoreWarn = edit.score == null && base.scoreWarn, + attempts = edit.attempts?.let { it.trim().ifEmpty { "-" } } ?: base.attempts, + attemptsWarn = edit.attempts == null && base.attemptsWarn, + login = edit.attended?.let { editedMark(it.value) } ?: base.login, + patrol = edit.dailyPatrol?.let { editedMark(it.value) } ?: base.patrol, + edited = true, + ) + } + + fun total(member: WeeklyReportBuilder.MemberRow): CellDisplay = CellDisplay( + merit = WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty), + meritWarn = WeeklyMetricPresentation.warnsBelowCutline(member.totalMerit, member.totalMeritCertainty, cutlines::belowWeeklyMerit), + score = if (isGunsmokeWeek) WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty) else null, + scoreWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(member.totalScore, member.totalScoreCertainty, cutlines::belowWeeklyScore), + attempts = if (isGunsmokeWeek) WeeklyMetricPresentation.format(member.totalAttempts, member.totalAttemptsCertainty) else null, + attemptsWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(member.totalAttempts, member.totalAttemptsCertainty, cutlines::belowWeeklyAttempts), + login = countMark(member.loginDays, member.loginDaysCertainty, cutlines::belowWeeklyLoginDays), + patrol = countMark(member.patrolDays, member.patrolDaysCertainty, cutlines::belowWeeklyPatrolDays), + edited = false, + ) + + private fun countMark(value: Int?, certainty: MetricCertainty, below: (Int) -> Boolean) = CellDisplay.Mark( + WeeklyMetricPresentation.format(value, certainty), + if (WeeklyMetricPresentation.warnsBelowCutline(value, certainty, below)) R.color.cutline_mark else R.color.text_secondary, + ) + + private fun activityMark(value: Boolean?, observed: Boolean, dayClosed: Boolean): CellDisplay.Mark = when { + value == true -> YES_MARK + !observed -> NONE_MARK + value == false && dayClosed -> NO_MARK + value == false -> NONE_MARK + else -> CellDisplay.Mark("?", R.color.text_secondary, R.string.evidence_unknown_short) + } + + private fun editedMark(value: Boolean?): CellDisplay.Mark = when (value) { + true -> YES_MARK + false -> NO_MARK + null -> NONE_MARK + } + + /** Today's column is marked with the accent so the open game day is found at a glance. */ + private fun headerCell(title: String, subtitle: String?, widthDp: Int, gravity: Int, today: Boolean = false): View = context.column { + this.gravity = gravity + setPadding(context.dp(10), 0, context.dp(6), 0) + setBackgroundColor(context.getColor(if (today) R.color.accent_surface else R.color.table_header)) + addView(context.text(title, TextRole.LABEL) { + setTextColor(context.getColor(if (today) R.color.accent_text else R.color.text_primary)) + this.gravity = gravity + }, context.lp()) + subtitle?.let { + addView(context.text(it, TextRole.CAPTION) { + this.gravity = gravity + if (today) setTextColor(context.getColor(R.color.accent_text)) + }, context.lp()) + } + if (today) contentDescription = context.getString(R.string.today_column, title, subtitle.orEmpty()) + layoutParams = LinearLayout.LayoutParams(width(widthDp), LinearLayout.LayoutParams.MATCH_PARENT) + } + + /** Grid row: hairline separators come from a 1px gap over the outline-colored row. */ + private val hairline = maxOf(1, context.dp(1) / 2) + + private fun gridRow(frozen: View, cells: List, height: Int): View { + val scroller = SyncedScrollView(context, sync).apply { + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + cells.forEach { cell -> + val cellWidth = (cell.layoutParams as? LinearLayout.LayoutParams)?.width ?: width(DAY_WIDTH) + addView(cell, LinearLayout.LayoutParams(cellWidth, height - hairline).apply { marginStart = hairline }) + } + }) + } + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + setBackgroundColor(context.getColor(R.color.outline)) + addView(frozen, LinearLayout.LayoutParams(width(MEMBER_WIDTH), height - hairline)) + addView(scroller, LinearLayout.LayoutParams(0, height, 1f)) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, height) + } + } + + companion object { + const val DAYS = 7 + const val MEMBER_WIDTH = 124 + const val DAY_WIDTH = 76 + const val TOTAL_WIDTH = 124 + const val HEADER_HEIGHT = 48 + const val STANDARD_ROW_HEIGHT = 64 + const val GUNSMOKE_ROW_HEIGHT = 80 + const val PODIUM = 3 + + private val YES_MARK = CellDisplay.Mark("✓", R.color.success_mark, R.string.yes) + private val NO_MARK = CellDisplay.Mark("×", R.color.failure_mark, R.string.no) + private val NONE_MARK = CellDisplay.Mark("-", R.color.text_secondary, R.string.not_observed) + private val UNKNOWN_MARK = CellDisplay.Mark("?", R.color.warning_mark, R.string.evidence_unknown_short) + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt index edc2907..1bf5477 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt @@ -1,164 +1,101 @@ package dev.gf2log.app -import android.app.Activity import android.content.Intent -import android.graphics.Typeface import android.os.Bundle -import android.view.Gravity -import android.view.ViewGroup -import android.widget.ImageButton import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.WeeklyReportHistoryEntry import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.text import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.concurrent.Executors -/** Lists the bounded immutable history for one weekly table. */ -class WeeklyTableHistoryActivity : LocalizedActivity() { +/** Lists the bounded immutable revisions recorded for one weekly table. */ +class WeeklyTableHistoryActivity : BaseActivity() { + + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository - private lateinit var body: LinearLayout - private val worker = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2WeeklyHistory") - } + private lateinit var content: LinearLayout + private val work = BackgroundWork(this, "GF2WeeklyHistory") private val periodStart: LocalDate by lazy { LocalDate.ofEpochDay(intent.getLongExtra(EXTRA_PERIOD_START, Long.MIN_VALUE)) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!intent.hasExtra(EXTRA_PERIOD_START)) { + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope + if (!intent.hasExtra(EXTRA_PERIOD_START) || scope == null) { finish() return } - repository = PlatoonRepository(this) - body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(8), dp(16), dp(16)) - addHeader() - addView(TextView(context).apply { - text = getString(R.string.weekly_table_history_description) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(2), 0, dp(12)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.weekly_report_loading) - gravity = Gravity.CENTER - setPadding(0, dp(28), 0, dp(28)) - }, matchWidth()) - } - setContentView(ScrollView(this).apply { addView(body, matchWidth()) }) - loadHistory() + repository = PlatoonRepository(this, scope) + val screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle( + getString(R.string.weekly_table_history), + getString( + R.string.week_range, + Labels.format(Labels.DATE, periodStart), + Labels.format(Labels.DATE, periodStart.plusDays(6)), + ), + ) + content = column() + screen.setBody(Screen.scrollingBody(this, content)) } - override fun onDestroy() { - worker.shutdownNow() - super.onDestroy() - } - - private fun LinearLayout.addHeader() { - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_arrow_back) - contentDescription = getString(R.string.back) - useModernIconStyle() - setPadding(dp(11), dp(11), dp(11), dp(11)) - setOnClickListener { finish() } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) }) - addView(TextView(context).apply { - text = getString(R.string.weekly_table_history) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - }, matchWidth()) - } - - private fun loadHistory() { - worker.execute { - val entries = repository.listWeeklyReportHistory(periodStart) - val models = entries.mapNotNull { entry -> - repository.weeklyReportHistory(entry.id, periodStart)?.let { report -> - HistoryRow(entry, report.members.size) - } - } - runOnUiThread { - if (!isFinishing && !isDestroyed) renderRows(models) - } - } - } - - private fun renderRows(rows: List) { - while (body.childCount > HEADER_CHILD_COUNT) body.removeViewAt(HEADER_CHILD_COUNT) - if (rows.isEmpty()) { - body.addView(TextView(this).apply { - text = getString(R.string.weekly_table_history_empty) - gravity = Gravity.CENTER - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(28), 0, dp(28)) - }, matchWidth()) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) { + finish() return } - val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .withLocale(resources.configuration.locales[0]) - .withZone(GameTimeZonePreferences.get(this)) - rows.forEachIndexed { index, row -> - body.addView( - ModernUi.actionRow( - context = this, - title = formatter.format(row.entry.recordedAt), - detail = buildString { - append(getString(R.string.weekly_table_history_member_count, row.memberCount)) - if (row.entry.active) { - append(" · ") - append(getString(R.string.weekly_table_history_active)) - } - }, - onClick = { - startActivityForResult( - Intent(this, WeeklyTableHistoryPreviewActivity::class.java) - .putExtra(EXTRA_PERIOD_START, periodStart.toEpochDay()) - .putExtra(WeeklyTableHistoryPreviewActivity.EXTRA_HISTORY_ID, row.entry.id), - REQUEST_PREVIEW, - ) - }, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { if (index > 0) topMargin = dp(8) }, - ) - } + load() } - @Deprecated("Activity result is retained for API 26 compatibility") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_PREVIEW && resultCode == Activity.RESULT_OK) { - setResult(Activity.RESULT_OK) - finish() + private fun load() { + if (content.childCount == 0) content.addView(StateViews.loading(this, getString(R.string.weekly_report_loading))) + work.run( + key = LOAD, + task = { + repository.listWeeklyReportHistory(periodStart) to + GameTimeZonePreferences.get(this, requireNotNull(profileBinding.scope).storageId) + }, + ) { result -> + content.removeAllViews() + val (entries, zone) = result.getOrElse { + content.addView(StateViews.failure(this, getString(R.string.weekly_report_load_failed)) { load() }) + return@run + } + content.addView(text(getString(R.string.weekly_table_history_description), TextRole.SECONDARY), lp(top = 4, bottom = 12)) + if (entries.isEmpty()) { + content.addView(StateViews.empty(this, R.drawable.ic_history_restore, getString(R.string.weekly_table_history_empty))) + return@run + } + content.addView(group(entries.map { entry -> + navigationRow( + title = Labels.format(Labels.DATE_TIME_SECONDS, entry.recordedAt, zone), + detail = if (entry.active) getString(R.string.weekly_table_history_active) else null, + icon = if (entry.active) R.drawable.ic_check_circle else R.drawable.ic_history_restore, + ) { + startActivity( + Intent(this, WeeklyTableHistoryPreviewActivity::class.java) + .putExtra(EXTRA_PERIOD_START, periodStart.toEpochDay()) + .putExtra(WeeklyTableHistoryPreviewActivity.EXTRA_HISTORY_ID, entry.id) + .putExtra(WeeklyTableHistoryPreviewActivity.EXTRA_RECORDED_AT, entry.recordedAt.toEpochMilli()), + ) + } + }), lp()) } } - private data class HistoryRow( - val entry: WeeklyReportHistoryEntry, - val memberCount: Int, - ) - - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - companion object { const val EXTRA_PERIOD_START = "weekly_history_period_start" - private const val REQUEST_PREVIEW = 80 - private const val HEADER_CHILD_COUNT = 2 + private const val LOAD = "load" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt index d6e2a87..767a9ad 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt @@ -1,176 +1,167 @@ package dev.gf2log.app -import android.app.Activity -import android.app.AlertDialog -import android.graphics.Bitmap -import android.graphics.Typeface +import android.content.Intent import android.os.Bundle -import android.view.Gravity import android.view.ViewGroup import android.widget.Button -import android.widget.HorizontalScrollView -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.management.WeeklyEvidenceAnalyzer +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.WeeklyCutlinePreferences +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.text +import java.time.Instant import java.time.LocalDate -import java.util.concurrent.Executors -/** Shows one immutable history projection and provides the explicit restore action. */ -class WeeklyTableHistoryPreviewActivity : LocalizedActivity() { +/** Read-only view of one recorded weekly table with the explicit restore action. */ +class WeeklyTableHistoryPreviewActivity : BaseActivity() { + + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository - private lateinit var body: LinearLayout - private val worker = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2WeeklyHistoryPreview") - } - private var bitmap: Bitmap? = null + private val work = BackgroundWork(this, "GF2WeeklyHistoryPreview") + private val topAdapter = SingleViewAdapter() + private val headerAdapter = SingleViewAdapter() + private val rowsAdapter = RowsAdapter() private val periodStart: LocalDate by lazy { LocalDate.ofEpochDay(intent.getLongExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START, 0L)) } private val historyId: Long by lazy { intent.getLongExtra(EXTRA_HISTORY_ID, -1L) } + private var restoreButton: Button? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (historyId < 0L || !intent.hasExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START)) { + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope + if (historyId < 0L || !intent.hasExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START) || scope == null) { finish() return } - repository = PlatoonRepository(this) - body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(8), dp(16), dp(16)) - addView(buildHeader(), matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.weekly_report_loading) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(32)) - }, matchWidth()) + repository = PlatoonRepository(this, scope) + val zone = GameTimeZonePreferences.get(this, scope.storageId) + val recordedAt = intent.getLongExtra(EXTRA_RECORDED_AT, -1L).takeIf { it >= 0 }?.let(Instant::ofEpochMilli) + val screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle( + getString(R.string.weekly_table_history_preview), + recordedAt?.let { Labels.format(Labels.DATE_TIME_SECONDS, it, zone) }, + ) + screen.setBody(RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(topAdapter, headerAdapter, rowsAdapter) + itemAnimator = null + }) + topAdapter.view = StateViews.loading(this, getString(R.string.weekly_report_loading)) + work.run( + task = { + repository.weeklyReportHistory(historyId, periodStart) to WeeklyCutlinePreferences(this, scope.storageId).read() + }, + ) { result -> + val (report, cutlines) = result.getOrNull() ?: (null to null) + if (report == null || cutlines == null) { + topAdapter.view = StateViews.empty(this, R.drawable.ic_history_restore, getString(R.string.weekly_table_history_unavailable)) + return@run + } + val table = WeeklyTable(this, report.isGunsmokeWeek, zone, cutlines) + topAdapter.view = column { + setPadding(dp(16), dp(4), dp(16), dp(12)) + addView(panel(emphasized = true, padding = 12) { + addView(text(getString(R.string.weekly_table_history_preview_notice), TextRole.SECONDARY) { + setTextColor(getColor(R.color.accent_text)) + }, lp()) + restoreButton = button(getString(R.string.weekly_table_history_restore), Controls.Role.PRIMARY) { + confirmRestore() + } + addView(restoreButton, lp(top = 8)) + }, lp()) + } + if (report.members.isNotEmpty()) headerAdapter.view = table.headerRow(report) + rowsAdapter.show(table, report) } - setContentView(ScrollView(this).apply { addView(body, matchWidth()) }) - loadPreview() - } - - override fun onDestroy() { - worker.shutdownNow() - bitmap?.recycle() - bitmap = null - super.onDestroy() } - private fun buildHeader() = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_arrow_back) - contentDescription = getString(R.string.back) - useModernIconStyle() - setPadding(dp(11), dp(11), dp(11), dp(11)) - setOnClickListener { finish() } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) }) - addView(TextView(context).apply { - text = getString(R.string.weekly_table_history_preview) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() } - private fun loadPreview() { - worker.execute { - val report = repository.weeklyReportHistory(historyId, periodStart) - val rendered = report?.let { - WeeklyReportPngRenderer.render( - WeeklyShareProjection.build( - report = it, - displayedMembers = it.members, - privateNotesByUid = emptyMap(), - privacy = WeeklyShareProjection.Privacy(), - ), - ) - } - runOnUiThread { - if (isFinishing || isDestroyed) { - rendered?.recycle() - } else if (rendered == null) { - showUnavailable() + private fun confirmRestore() { + Dialogs.confirm( + this, + R.string.weekly_table_history_restore, + R.string.weekly_table_history_restore_warning, + R.string.weekly_table_history_restore, + ) confirm@{ + if (work.isBusy(RESTORE)) return@confirm + restoreButton?.isEnabled = false + work.run(key = RESTORE, task = { repository.restoreWeeklyReportHistory(historyId, periodStart) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(this, R.string.weekly_table_history_restored) + startActivity( + Intent(this, WeeklyReportActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() } else { - bitmap = rendered - renderPreview(rendered) + restoreButton?.isEnabled = true + Feedback.show(this, R.string.weekly_table_history_restore_failed, long = true) } } } } - private fun renderPreview(image: Bitmap) { - while (body.childCount > 1) body.removeViewAt(1) - body.addView(HorizontalScrollView(this).apply { - isFillViewport = true - addView(ImageView(context).apply { - setImageBitmap(image) - adjustViewBounds = true - contentDescription = getString(R.string.weekly_table_history_preview) - }, ViewGroup.LayoutParams(image.width, image.height)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - body.addView(Button(this).apply { - text = getString(R.string.weekly_table_history_restore) - usePrimaryActionStyle() - setOnClickListener { confirmRestore() } - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(48), - ).apply { topMargin = dp(16) }) - } + private inner class RowsAdapter : RecyclerView.Adapter() { + private var table: WeeklyTable? = null + private var report: WeeklyReportBuilder.Report? = null - private fun showUnavailable() { - while (body.childCount > 1) body.removeViewAt(1) - body.addView(TextView(this).apply { - text = getString(R.string.weekly_table_history_restore_failed) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(32)) - }, matchWidth()) - } + fun show(nextTable: WeeklyTable, nextReport: WeeklyReportBuilder.Report) { + table = nextTable + report = nextReport + notifyDataSetChanged() + } - private fun confirmRestore() { - AlertDialog.Builder(this) - .setTitle(R.string.weekly_table_history_restore) - .setMessage(R.string.weekly_table_history_restore_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.weekly_table_history_restore) { _, _ -> restore() } - .show() - } + override fun getItemCount() = report?.members?.size ?: 0 - private fun restore() { - worker.execute { - val restored = repository.restoreWeeklyReportHistory(historyId, periodStart) - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (restored) { - TransientMessage.show(this, R.string.weekly_table_history_restored) - setResult(Activity.RESULT_OK) - finish() - } else { - TransientMessage.show( - this, - R.string.weekly_table_history_restore_failed, - Toast.LENGTH_LONG, - ) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = RowHolder(requireNotNull(table).MemberRow()) + + override fun onBindViewHolder(holder: RowHolder, position: Int) { + val current = report ?: return + val member = current.members[position] + holder.row.bind(member, (position + 1).takeIf { current.isGunsmokeWeek }, emptyMap()) { cell -> + val label = cell?.let { Labels.dayHeader(this@WeeklyTableHistoryPreviewActivity, it.gameDay) } ?: getString(R.string.total) + WeeklyDialogs.showEvidence( + this@WeeklyTableHistoryPreviewActivity, + getString(R.string.weekly_cell_title, member.name, label), + current.isGunsmokeWeek, + cell?.let { requireNotNull(table).daily(it) } ?: requireNotNull(table).total(member), + WeeklyEvidenceAnalyzer.Metric.entries.associateWith { metric -> + if (cell == null) WeeklyEvidenceAnalyzer.explainTotal(member, metric) else WeeklyEvidenceAnalyzer.explainDaily(cell, metric) + }, + ) } } } - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private class RowHolder(val row: WeeklyTable.MemberRow) : RecyclerView.ViewHolder(row.view) companion object { const val EXTRA_HISTORY_ID = "weekly_history_id" + const val EXTRA_RECORDED_AT = "weekly_history_recorded_at" + private const val RESTORE = "restore" } } diff --git a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt new file mode 100644 index 0000000..4f23d9b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt @@ -0,0 +1,85 @@ +package dev.gf2log.app + +import dev.gf2log.app.management.ManualTextPolicy +import android.app.Activity +import android.app.AlertDialog +import android.text.InputType +import android.view.View +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.isValidMembershipRange +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.textField + +/** Records a member who left before capture began, with a required join and withdrawal date. */ +internal object WithdrawnMemberDialog { + fun show( + activity: Activity, + work: BackgroundWork, + repository: PlatoonRepository, + isKnownUid: (Long) -> Boolean, + onAdded: () -> Unit, + ) = with(activity) { + val zone = repository.gameZone() + val uid = LabeledField(this, getString(R.string.uid), textField(getString(R.string.uid_hint), inputType = InputType.TYPE_CLASS_NUMBER)) + val nickname = LabeledField(this, getString(R.string.member_nickname), textField(getString(R.string.member_nickname), maxLength = ManualTextPolicy.MAX_MEMBER_NAME_CHARS)) + val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone) + val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), dateRequired = true, zone = zone) + val note = LabeledField( + this, + getString(R.string.membership_note_hint), + textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS), + ) + val error = errorLine() + val content = Dialogs.content(this) { + listOf(uid, nickname, joined, withdrew, note, error).forEachIndexed { index, view -> + addView(view, lp(top = if (index == 0) 0 else 12)) + } + } + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.add_withdrawn_member) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { dialog -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> + val uidValue = uid.input.text.toString().trim().toLongOrNull()?.takeIf { it > 0 } + val name = nickname.input.text.toString().trim() + val joinedBoundary = joined.boundary + val withdrewBoundary = withdrew.boundary + val problem = when { + uidValue == null -> R.string.invalid_uid + isKnownUid(uidValue) -> R.string.duplicate_uid + name.isEmpty() -> R.string.invalid_nickname + joinedBoundary == null || withdrewBoundary == null -> R.string.invalid_dates_required + !isValidMembershipRange(joinedBoundary, withdrewBoundary) -> R.string.invalid_date_order + else -> null + } + if (problem != null || uidValue == null || joinedBoundary == null || withdrewBoundary == null) { + error.showMessage(getString(problem ?: R.string.invalid_withdrawn_member)) + return@setOnClickListener + } + button.isEnabled = false + val noteText = note.input.text.toString() + work.run( + task = { repository.addWithdrawnMember(uidValue, name, joinedBoundary, withdrewBoundary, noteText) }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + Feedback.show(this, R.string.saved) + onAdded() + } else { + error.showMessage(getString(R.string.invalid_withdrawn_member)) + } + } + } + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt index 0b2974e..8658dcc 100644 --- a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -2,7 +2,7 @@ package dev.gf2log.app.capture /** * Bounded pre-identity quarantine; overflow permanently rejects that flow until closure. - * All state access is serialized because rejected-close cleanup can run off the parser executor. + * Capture confines it to the worker thread; access stays serialized so sharing it remains safe. */ internal class BoundedFlowPayloadBuffer( private val maxItemsPerFlow: Int, diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt new file mode 100644 index 0000000..b1716b7 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -0,0 +1,319 @@ +package dev.gf2log.app.capture + +import android.content.Context +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.PayloadHistoryPreferences +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.Gfl2StreamParser +import dev.gf2log.protocol.model.ParseEvent +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.PlatoonProfileData +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Parses each TCP flow, identifies its Platoon from 21905, and routes payloads to that verified + * scope. Everything except the [nativeListener] callbacks runs on the [CaptureWorker], which + * therefore owns all of this state without locking. + */ +internal class CaptureFlowEngine( + private val context: Context, + private val worker: CaptureWorker, + private val stats: CaptureTrafficStats, + private val profileRegistry: PlatoonProfileRegistry, + private val clientServerRegions: ClientServerRegionPreferences, + private val payloadHistory: PayloadHistoryPreferences, +) : PlatoonAdmissionCoordinator.Flows { + /** The native thread taints a flow whose chunk was dropped; it stays ignored until closure. */ + private val taintedFlows: MutableSet = ConcurrentHashMap.newKeySet() + private val parsers = HashMap() + private val metadata = HashMap() + private val sessions = HashMap() + private val preIdentity = BoundedFlowPayloadBuffer( + MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW, + MAX_PRE_IDENTITY_PAYLOADS_TOTAL, + ) + private val historyStores = HashMap() + private val observedProfiles = HashMap>() + private var ownerResolver = CaptureFlowOwnerResolver(context) + private var tracker: CaptureRunTracker? = null + + val admission = PlatoonAdmissionCoordinator(profileRegistry, clientServerRegions, this) + + fun beginSession(tracker: CaptureRunTracker) { + this.tracker = tracker + ownerResolver = CaptureFlowOwnerResolver(context) + admission.beginSession() + } + + /** Discards per-flow state after the native thread has been joined and its chunks drained. */ + fun endSession() { + admission.endSession() + sessions.clear() + parsers.clear() + metadata.clear() + taintedFlows.clear() + preIdentity.clear() + observedProfiles.clear() + tracker = null + } + + fun nativeListener(onStopped: () -> Unit) = object : NativeCaptureBridge.PayloadListener { + override fun onFlowOpened( + flowId: Long, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) { + submit { open(flowId, protocol, localAddress, localPort, remoteAddress, remotePort) } + } + + override fun onPayload(flowId: Long, payload: ByteArray) { + if (payload.isEmpty() || flowId in taintedFlows) return + // A missing TCP chunk makes every later byte offset unreliable. + if (!submit { accept(flowId, payload) }) taintedFlows += flowId + } + + override fun onFlowClosed(flowId: Long) { + // Earlier chunks may still be queued; the taint keeps them inert until session end. + if (!submit { close(flowId) }) taintedFlows += flowId + } + + override fun onTraffic(sentBytes: Long, receivedBytes: Long, inspectedBytes: Long) { + stats.recordTraffic(sentBytes + receivedBytes, inspectedBytes)?.let { args -> + CaptureStatus.report(CaptureNotice.TRAFFIC, *args.toTypedArray()) + } + } + + override fun onCaptureStopped() = onStopped() + } + + override fun isOpen(flowId: Long): Boolean = flowId in metadata || flowId in parsers + + override fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession = + PlatoonCaptureSession( + context = context, + profile = profile, + onRosterCaptured = { storageId -> tracker?.rosterCaptured(storageId) }, + ).also { sessions[flowId] = it } + + override fun unbind(flowId: Long, session: PlatoonCaptureSession) { + sessions.remove(flowId, session) + } + + /** Permanently blocks management routing for this flow until native closure. */ + override fun quarantine(flowId: Long) { + taintedFlows += flowId + sessions.remove(flowId) + preIdentity.reject(flowId) + } + + override fun profileChanged(storageId: String) { + observedProfiles.remove(storageId) + } + + override fun route(session: PlatoonCaptureSession, payload: ParsedPayload) { + val storageId = session.profile.storageId + saveHistory(storageId, payload) + val routed = session.dispatch(payload) + routed.activity + ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) } + ?.onFailure { CaptureStatus.report(CaptureNotice.ACTIVITY_SAVE_FAILED) } + routed.updates + ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) } + ?.onFailure { CaptureStatus.report(CaptureNotice.UPDATES_SAVE_FAILED) } + routed.members + ?.onSuccess { saved -> + if (saved != null) { + CaptureStatus.report( + CaptureNotice.ROSTER_SAVED, + saved.rowCount.toString(), + session.profile.platoonName, + ) + } + } + ?.onFailure { CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) } + if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { + tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) + } + } + + private fun submit(task: () -> Unit): Boolean = worker.submitChunk(task).also { queued -> + if (!queued) { + CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED, stats.recordDropped().toString()) + } + } + + private fun open( + flowId: Long, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) { + if (flowId in taintedFlows) return + metadata[flowId] = CaptureFlowMetadata( + protocol = protocol, + localAddress = localAddress, + localPort = localPort, + remoteAddress = remoteAddress, + remotePort = remotePort, + ownerPackage = ownerResolver.resolve( + protocol, + localAddress, + localPort, + remoteAddress, + remotePort, + ), + ) + } + + private fun accept(flowId: Long, payload: ByteArray) { + if (flowId in taintedFlows) return + process(flowId, parsers.getOrPut(flowId, ::Gfl2StreamParser).accept(payload)) + } + + private fun close(flowId: Long) { + val parser = parsers.remove(flowId) + if (!taintedFlows.remove(flowId) && parser != null) { + process(flowId, parser.finish()) + } + metadata.remove(flowId) + admission.flowClosed(flowId) + preIdentity.remove(flowId) + sessions.remove(flowId) + } + + private fun process(flowId: Long, events: List) { + if (events.isEmpty()) return + val payloads = mutableListOf() + var warnings = 0 + events.forEach { event -> + when (event) { + is ParseEvent.Payload -> payloads += event.value + is ParseEvent.Warning -> warnings += 1 + is ParseEvent.UnknownPayload -> stats.recordUnknown(event.payloadType) + } + } + stats.recordParsed(payloads.size, warnings) + + payloads.forEach { payload -> + val profile = payload.data as? PlatoonProfileData + if (profile != null && PlatoonProfilePolicy.isValid(profile)) identify(flowId, profile) + val session = sessions[flowId] + if (session == null) { + holdUntilIdentified(flowId, payload) + } else { + route(session, payload) + } + } + } + + private fun identify(flowId: Long, data: PlatoonProfileData) { + if (preIdentity.isRejected(flowId)) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW) + return + } + val ownerPackage = metadata[flowId]?.ownerPackage + val client = PlatoonClient.fromPackage(ownerPackage) + if (ownerPackage == null || ownerPackage !in SupportedGamePackages.all || client == null) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.CLIENT_UNVERIFIED) + return + } + if (admission.claimsIdentity(flowId, ownerPackage, data)) return + + val known = findKnownProfile(client, ownerPackage, data) + val current = sessions[flowId] + if (PlatoonProfilePolicy.changesBoundIdentity(current?.profile?.storageId, known?.storageId)) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.IDENTITY_CHANGED) + return + } + if (known == null) { + admission.begin(flowId, ownerPackage, data, preIdentity.take(flowId)) + return + } + val profile = observe(known.storageId, data) + if (profile == null) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.PROFILE_UPDATE_FAILED) + return + } + // An already bound flow only refreshes its profile; the caller routes this 21905 itself. + if (current != null) return + + val session = bind(flowId, profile) + preIdentity.take(flowId).forEach { pending -> route(session, pending) } + CaptureStatus.report( + CaptureNotice.PLATOON_DETECTED, + profile.platoonName.take(PlatoonAdmissionCoordinator.MAX_STATUS_NAME_CHARS), + profile.platoonId.toString(), + profile.client.displayName, + ) + } + + private fun findKnownProfile( + client: PlatoonClient, + ownerPackage: String, + data: PlatoonProfileData, + ): PlatoonProfile? { + val known = profileRegistry.findByClientAndPlatoonId(client, data.platoonId.toLong()) + return when { + known.size == 1 -> known.single() + known.size > 1 -> { + val configuredRegion = clientServerRegions.configured(ownerPackage) + known.singleOrNull { it.serverRegion == configuredRegion } + } + else -> null + } + } + + /** Persists an observed name or banner once per session, and again only when it changes. */ + private fun observe(storageId: String, data: PlatoonProfileData): PlatoonProfile? { + val identity = ObservedIdentity(data.platoonName, data.bannerFrameId, data.bannerMarkId) + observedProfiles[storageId]?.let { (observed, profile) -> if (observed == identity) return profile } + val profile = runCatching { profileRegistry.updateObserved(storageId, data) }.getOrNull() + ?: return null + observedProfiles[storageId] = identity to profile + return profile + } + + private fun holdUntilIdentified(flowId: Long, payload: ParsedPayload) { + if (admission.retain(flowId, payload)) return + if (preIdentity.offer(flowId, payload) == BoundedFlowPayloadBuffer.OfferResult.OVERFLOW) { + CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) + } + } + + private fun saveHistory(storageId: String, payload: ParsedPayload) { + if (!payloadHistory.isEnabled(payload.payloadType)) return + val store = historyStores.getOrPut(storageId) { + CaptureHistoryStore( + File( + PlatoonStorageScope(storageId).rootDirectory(context), + CaptureHistoryStore.HISTORY_DIRECTORY, + ), + ) + } + runCatching { store.save(payload) } + .onFailure { CaptureStatus.report(CaptureNotice.HISTORY_SAVE_FAILED) } + } + + private data class ObservedIdentity(val name: String, val bannerFrameId: UInt, val bannerMarkId: UInt) + + private companion object { + const val MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW = 32 + const val MAX_PRE_IDENTITY_PAYLOADS_TOTAL = 128 + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt index 8dcbe2d..fac06ac 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt @@ -16,43 +16,41 @@ internal data class CaptureFlowMetadata( val ownerPackage: String?, ) -/** Resolves an original VPN flow to a supported Android package without endpoint guessing. */ -internal object CaptureFlowOwnerResolver { +/** + * Resolves an original VPN flow to a supported Android package without endpoint guessing. + * One instance serves one capture session, so package UIDs are looked up once, not per flow. + */ +internal class CaptureFlowOwnerResolver(private val context: Context) { + private val packageByUid: Map by lazy { + SupportedGamePackages.all.mapNotNull { packageName -> + runCatching { + context.packageManager.getApplicationInfo(packageName, 0).uid to packageName + }.getOrNull() + }.toMap() + } + fun resolve( - context: Context, protocol: Int, localAddress: String, localPort: Int, remoteAddress: String, remotePort: Int, ): String? { - val visiblePackages = SupportedGamePackages.all.mapNotNull { packageName -> - runCatching { - packageName to context.packageManager.getApplicationInfo(packageName, 0).uid - }.getOrNull() - } val resolved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val connectivity = context.getSystemService(ConnectivityManager::class.java) val local = InetSocketAddress(InetAddress.getByName(localAddress), localPort) val remote = InetSocketAddress(InetAddress.getByName(remoteAddress), remotePort) - val forwardUid = runCatching { - connectivity.getConnectionOwnerUid( - protocol, - local, - remote, - ) - }.getOrNull()?.takeIf { it >= 0 } - val forwardPackage = visiblePackages.firstOrNull { it.second == forwardUid }?.first - val reverseUid = if (forwardPackage == null) runCatching { - // Some vendor network stacks report VPN tuples in the opposite direction. - connectivity.getConnectionOwnerUid(protocol, remote, local) - }.getOrNull()?.takeIf { it >= 0 } else null - forwardPackage ?: visiblePackages.firstOrNull { it.second == reverseUid }?.first + fun owner(source: InetSocketAddress, destination: InetSocketAddress): String? = + runCatching { connectivity.getConnectionOwnerUid(protocol, source, destination) } + .getOrNull() + ?.takeIf { it >= 0 } + ?.let(packageByUid::get) + // Some vendor network stacks report VPN tuples in the opposite direction. + owner(local, remote) ?: owner(remote, local) } else { null } - val installed = visiblePackages.map { it.first } - return CaptureFlowOwnerPolicy.choose(resolved, installed) + return CaptureFlowOwnerPolicy.choose(resolved, packageByUid.values) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt deleted file mode 100644 index 1e187e6..0000000 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.gf2log.app.capture - -/** Removes every piece of per-flow state even when no stream parser was ever created. */ -internal object CaptureFlowStateCleanup { - /** - * Publishes metadata unless an out-of-band close rejection already quarantined the flow. - * The post-write check covers both possible races: close-before-write and close-after-write. - */ - fun registerUnlessQuarantined( - flowId: Long, - value: CaptureFlowMetadata, - metadata: MutableMap, - quarantinedFlows: Set, - ): Boolean { - metadata[flowId] = value - if (flowId !in quarantinedFlows) return true - metadata.remove(flowId) - return false - } - - fun remove( - flowId: Long, - parsers: MutableMap, - metadata: MutableMap, - ): T? { - metadata.remove(flowId) - return parsers.remove(flowId) - } -} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt new file mode 100644 index 0000000..a046a5e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt @@ -0,0 +1,57 @@ +package dev.gf2log.app.capture + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.StringRes +import dev.gf2log.app.R +import dev.gf2log.app.settings.CapturePreferences + +/** The foreground notification Android requires while VPN capture is active. */ +internal class CaptureNotifications(private val service: Service) { + private val preferences = CapturePreferences(service) + private val manager = service.getSystemService(NotificationManager::class.java) + + fun createChannel() { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL, + service.getString(R.string.notification_channel), + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + + fun startForeground(@StringRes content: Int) { + val notification = build(content) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.startForeground(ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + service.startForeground(ID, notification) + } + } + + fun update(@StringRes content: Int) = manager.notify(ID, build(content)) + + fun remove() = service.stopForeground(Service.STOP_FOREGROUND_REMOVE) + + private fun build(@StringRes content: Int): Notification = + Notification.Builder(service, CHANNEL) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(service.getString(R.string.app_name)) + .setContentText( + service.getString( + if (preferences.detailedNotifications) content else R.string.notification_capture_active, + ), + ) + .setOngoing(true) + .build() + + private companion object { + const val CHANNEL = "capture" + const val ID = 1 + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt new file mode 100644 index 0000000..0bf8a33 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt @@ -0,0 +1,45 @@ +package dev.gf2log.app.capture + +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.PayloadCatalog + +/** + * One capture run's required-payload checklist. Marks arrive on the capture worker; a one-time + * capture reports completion and the target roster through callbacks that must hop to main. + */ +internal class CaptureRunTracker( + val captureOnce: Boolean, + private val onComplete: (CaptureRunTracker) -> Unit = {}, + private val onTargetRosterCaptured: (CaptureRunTracker) -> Unit = {}, +) { + private val checklist = ScopedCaptureChecklist(REQUIRED_TYPES) + + fun mark(storageId: String, payloadType: Int) { + checklist.mark(storageId, payloadType, chooseTarget = captureOnce) + if (!captureOnce || checklist.targetScopeId() == storageId) { + CaptureStatus.markUsefulPayload(payloadType) + } + if (captureOnce && checklist.targetComplete()) onComplete(this) + } + + fun rosterCaptured(storageId: String) { + mark(storageId, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) + if (captureOnce && checklist.targetScopeId() == storageId) onTargetRosterCaptured(this) + } + + /** Tags the target scope still lacks, or null until that scope's roster was captured. */ + fun missingAfterRoster(): List? { + val captured = checklist.targetCaptured() + if (Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS !in captured) return null + return REQUIRED_TYPES.minus(captured).map(PayloadCatalog::tag) + } + + companion object { + val REQUIRED_TYPES = setOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + ) + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt index 63bb654..25f5227 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -1,62 +1,172 @@ package dev.gf2log.app.capture +import android.content.Context +import androidx.annotation.StringRes +import dev.gf2log.app.R +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +enum class CapturePhase { STOPPED, STARTING, RUNNING } + +/** Every user-visible capture status message, localized through [messageRes]. */ +enum class CaptureNotice(@StringRes val messageRes: Int, val severity: Severity) { + PREPARING(R.string.capture_notice_preparing, Severity.INFO), + ALREADY_RUNNING(R.string.capture_notice_already_running, Severity.INFO), + /** Args: comma-separated captured package IDs. */ + CAPTURING(R.string.capture_notice_capturing, Severity.INFO), + NATIVE_UNAVAILABLE(R.string.capture_notice_native_unavailable, Severity.ERROR), + HISTORY_IMPORT_FAILED(R.string.capture_notice_history_import_failed, Severity.ERROR), + NO_SUPPORTED_CLIENT(R.string.capture_notice_no_supported_client, Severity.ERROR), + /** Args: failure reason. */ + VPN_ESTABLISH_FAILED(R.string.capture_notice_vpn_establish_failed, Severity.ERROR), + VPN_PERMISSION_REVOKED(R.string.capture_notice_vpn_permission_revoked, Severity.ERROR), + NATIVE_START_FAILED(R.string.capture_notice_native_start_failed, Severity.ERROR), + STOPPED_UNEXPECTEDLY(R.string.capture_notice_stopped_unexpectedly, Severity.ERROR), + CAPTURE_ONCE_COMPLETE(R.string.capture_notice_capture_once_complete, Severity.INFO), + /** Args: comma-separated missing payload tags. */ + CAPTURE_ONCE_PARTIAL(R.string.capture_notice_capture_once_partial, Severity.WARNING), + /** Args: forwarded KiB, inspected KiB, decoded payloads, warnings, dropped chunks. */ + TRAFFIC(R.string.capture_notice_traffic, Severity.INFO), + /** Args: exception class name. */ + PROCESSING_FAILED(R.string.capture_notice_processing_failed, Severity.ERROR), + /** Args: total dropped chunks. */ + PARSER_OVERLOADED(R.string.capture_notice_parser_overloaded, Severity.WARNING), + FLOW_BUFFER_OVERFLOW(R.string.capture_notice_flow_buffer_overflow, Severity.WARNING), + IDENTIFIED_FLOW_BUFFER_OVERFLOW( + R.string.capture_notice_identified_flow_buffer_overflow, + Severity.WARNING, + ), + CLIENT_UNVERIFIED(R.string.capture_notice_client_unverified, Severity.WARNING), + IDENTITY_CHANGED(R.string.capture_notice_identity_changed, Severity.WARNING), + PENDING_IDENTITY_CHANGED(R.string.capture_notice_pending_identity_changed, Severity.WARNING), + PROFILE_UPDATE_FAILED(R.string.capture_notice_profile_update_failed, Severity.ERROR), + /** Args: Platoon name, Platoon ID, client name. */ + PLATOON_DETECTED(R.string.capture_notice_platoon_detected, Severity.INFO), + PENDING_DETECTED(R.string.capture_notice_pending_detected, Severity.INFO), + PENDING_QUEUE_FULL(R.string.capture_notice_pending_queue_full, Severity.WARNING), + PENDING_OVERFLOW(R.string.capture_notice_pending_overflow, Severity.WARNING), + ADMISSION_IN_PROGRESS(R.string.capture_notice_admission_in_progress, Severity.INFO), + ADMISSION_INVALID(R.string.capture_notice_admission_invalid, Severity.ERROR), + ADMISSION_REGION_MISMATCH(R.string.capture_notice_admission_region_mismatch, Severity.ERROR), + ADMISSION_LIMIT(R.string.capture_notice_admission_limit, Severity.WARNING), + ADMISSION_PROFILE_FAILED(R.string.capture_notice_admission_profile_failed, Severity.ERROR), + ADMISSION_REPLAY_FAILED(R.string.capture_notice_admission_replay_failed, Severity.ERROR), + /** Args: Platoon name, Platoon ID, client name. */ + PLATOON_CONFIRMED(R.string.capture_notice_platoon_confirmed, Severity.INFO), + PENDING_DISCARDED(R.string.capture_notice_pending_discarded, Severity.INFO), + /** Args: member count, Platoon name. */ + ROSTER_SAVED(R.string.capture_notice_roster_saved, Severity.INFO), + ROSTER_SAVE_FAILED(R.string.capture_notice_roster_save_failed, Severity.ERROR), + /** Args: Platoon name, joined count, left count. */ + ROSTER_UPDATED(R.string.capture_notice_roster_updated, Severity.INFO), + /** Args: Platoon name. */ + ROSTER_RECOVERED(R.string.capture_notice_roster_recovered, Severity.WARNING), + ACTIVITY_SAVE_FAILED(R.string.capture_notice_activity_save_failed, Severity.ERROR), + UPDATES_SAVE_FAILED(R.string.capture_notice_updates_save_failed, Severity.ERROR), + HISTORY_SAVE_FAILED(R.string.capture_notice_history_save_failed, Severity.ERROR), + ; + + enum class Severity { INFO, WARNING, ERROR } +} + +data class CaptureNoticeEvent( + val notice: CaptureNotice, + val args: List = emptyList(), + val atMillis: Long, +) + +fun CaptureNoticeEvent.format(context: Context): String = + context.getString(notice.messageRes, *args.toTypedArray()) + +data class CaptureSnapshot( + val phase: CapturePhase, + val notice: CaptureNoticeEvent?, + val capturedTargets: List, + val guided: CaptureStatus.GuidedProgress?, +) + +/** Process-wide capture state, swapped atomically as one immutable [CaptureSnapshot]. */ object CaptureStatus { - private const val DEFAULT_MESSAGE = "Capture is stopped" + private val state = AtomicReference( + CaptureSnapshot(CapturePhase.STOPPED, notice = null, capturedTargets = emptyList(), guided = null), + ) + private val listeners = CopyOnWriteArraySet<(CaptureSnapshot) -> Unit>() + private val dispatchPending = AtomicBoolean(false) - @Volatile - private var message = DEFAULT_MESSAGE + fun snapshot(): CaptureSnapshot = state.get() - @Volatile - var isRunning: Boolean = false - private set + val isRunning: Boolean + get() = snapshot().phase == CapturePhase.RUNNING - @Volatile - var isStarting: Boolean = false - private set + /** [listener] runs on the main looper: once immediately, then after every change. */ + fun addListener(listener: (CaptureSnapshot) -> Unit) { + listeners += listener + MainThread.runOrPost { if (listener in listeners) listener(snapshot()) } + } - @Volatile - private var guidedProgress: GuidedProgress? = null + fun removeListener(listener: (CaptureSnapshot) -> Unit) { + listeners -= listener + } - fun read(): String = message + fun markStarting() = update { + it.copy(phase = CapturePhase.STARTING, notice = event(CaptureNotice.PREPARING)) + } - fun readGuidedProgress(): GuidedProgress? = guidedProgress + fun markStopped() = markStopped(notice = null) - fun update(message: String) { - this.message = message + internal fun markStopped(notice: CaptureNotice?, vararg args: String) = update { + it.copy( + phase = CapturePhase.STOPPED, + notice = notice?.let { value -> event(value, args) }, + capturedTargets = emptyList(), + ) } - fun markRunning(message: String, guided: Boolean = guidedProgress != null) { - isRunning = true - isStarting = false - this.message = message - if (guided && guidedProgress == null) guidedProgress = GuidedProgress() + internal fun markRunning(targets: List) = update { + it.copy( + phase = CapturePhase.RUNNING, + notice = event(CaptureNotice.CAPTURING, arrayOf(targets.joinToString())), + capturedTargets = targets.toList(), + ) } - fun markStarting(message: String = "Preparing capture") { - isRunning = false - isStarting = true - this.message = message + internal fun report(notice: CaptureNotice, vararg args: String) = update { + it.copy(notice = event(notice, args)) } - fun markStopped(message: String = DEFAULT_MESSAGE) { - isRunning = false - isStarting = false - this.message = message + internal fun beginSession(guided: Boolean) = update { + it.copy(guided = GuidedProgress().takeIf { guided }) } - fun beginSession(guided: Boolean) { - guidedProgress = GuidedProgress().takeIf { guided } + internal fun markUsefulPayload(payloadType: Int) = update { current -> + val guided = current.guided ?: return@update current + if (payloadType in guided.capturedPayloadTypes) return@update current + current.copy(guided = guided.copy(capturedPayloadTypes = guided.capturedPayloadTypes + payloadType)) } - fun markUsefulPayload(payloadType: Int) { - val current = guidedProgress ?: return - guidedProgress = current.copy(capturedPayloadTypes = current.capturedPayloadTypes + payloadType) + private inline fun update(transform: (CaptureSnapshot) -> CaptureSnapshot) { + while (true) { + val previous = state.get() + val next = transform(previous) + if (next === previous) return + if (state.compareAndSet(previous, next)) break + } + notifyListeners() } - fun clearSession() { - guidedProgress = null + private fun notifyListeners() { + if (listeners.isEmpty() || !dispatchPending.compareAndSet(false, true)) return + MainThread.post { + dispatchPending.set(false) + val current = snapshot() + listeners.forEach { it(current) } + } } + private fun event(notice: CaptureNotice, args: Array = emptyArray()) = + CaptureNoticeEvent(notice, args.toList(), System.currentTimeMillis()) + data class GuidedProgress( val capturedPayloadTypes: Set = emptySet(), ) diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt new file mode 100644 index 0000000..5000ebf --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt @@ -0,0 +1,76 @@ +package dev.gf2log.app.capture + +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** One capture session's counters, written by the native and worker threads. */ +internal class CaptureTrafficStats { + private val forwardedBytes = AtomicLong() + private val inspectedBytes = AtomicLong() + private val decodedPayloads = AtomicLong() + private val warnings = AtomicLong() + private val droppedChunks = AtomicLong() + private val reportedBucket = AtomicLong() + private val unknownPayloads = ConcurrentHashMap() + + @Volatile + private var startedAt: Instant? = null + + fun begin(now: Instant = Instant.now()) { + listOf(forwardedBytes, inspectedBytes, decodedPayloads, warnings, droppedChunks, reportedBucket) + .forEach { it.set(0) } + unknownPayloads.clear() + startedAt = now + } + + fun recordParsed(decoded: Int, warnings: Int) { + if (decoded > 0) decodedPayloads.addAndGet(decoded.toLong()) + if (warnings > 0) this.warnings.addAndGet(warnings.toLong()) + } + + fun recordUnknown(payloadType: Int) { + unknownPayloads.computeIfAbsent(payloadType) { AtomicLong() }.incrementAndGet() + } + + fun recordDropped(): Long = droppedChunks.incrementAndGet() + + /** Returns [CaptureNotice.TRAFFIC] args each time forwarded traffic crosses another bucket. */ + fun recordTraffic(forwarded: Long, inspected: Long): List? { + forwardedBytes.set(forwarded) + inspectedBytes.set(inspected) + val bucket = forwarded / TRAFFIC_REPORT_BYTES + val previous = reportedBucket.get() + if (bucket <= previous || !reportedBucket.compareAndSet(previous, bucket)) return null + return listOf( + (forwarded / 1024).toString(), + (inspected / 1024).toString(), + decodedPayloads.get().toString(), + warnings.get().toString(), + droppedChunks.get().toString(), + ) + } + + /** Ends the session; returns null when no tunnel was established since [begin]. */ + fun finish(finalStatus: String, stoppedAt: Instant = Instant.now()): CaptureDiagnosticsStore.Diagnostics? { + val started = startedAt ?: return null + startedAt = null + return CaptureDiagnosticsStore.Diagnostics( + startedAt = started, + stoppedAt = stoppedAt, + forwardedBytes = forwardedBytes.get(), + inspectedBytes = inspectedBytes.get(), + decodedPayloads = decodedPayloads.get(), + warnings = warnings.get(), + droppedChunks = droppedChunks.get(), + unknownPayloads = unknownPayloads.entries + .sortedBy { it.key } + .joinToString { "${it.key}:${it.value.get()}" }, + finalStatus = finalStatus, + ) + } + + private companion object { + const val TRAFFIC_REPORT_BYTES = 64 * 1024 + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt index 4489014..d83f776 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -1,142 +1,87 @@ package dev.gf2log.app.capture -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.Service import android.content.Intent import android.content.pm.PackageManager -import android.content.pm.ServiceInfo import android.net.VpnService -import android.os.Build import android.os.Handler import android.os.Looper import android.os.ParcelFileDescriptor import dev.gf2log.app.R import dev.gf2log.app.SupportedGamePackages -import dev.gf2log.app.history.CaptureHistoryStore -import dev.gf2log.app.management.PlatoonClient -import dev.gf2log.app.management.PlatoonProfileIdentity import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.settings.CapturePreferences import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.PayloadHistoryPreferences -import dev.gf2log.protocol.Gfl2StreamParser -import dev.gf2log.protocol.Gfl2PayloadDecoder -import dev.gf2log.protocol.PayloadCatalog -import dev.gf2log.protocol.model.ParseEvent -import dev.gf2log.protocol.model.ParsedPayload -import dev.gf2log.protocol.model.PlatoonProfileData -import java.io.File -import java.time.Instant -import java.util.concurrent.ArrayBlockingQueue -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong +/** + * Lifecycle glue between Android's VPN service, the native forwarding core, and the capture worker. + * + * Every field is confined to the main thread. Blocking work (history migration, joining the native + * thread, draining queued chunks) runs on [CaptureWorker], whose FIFO order guarantees teardown as + * "stop native, then drain parser, then clear state". A start requested during teardown waits for it. + */ class CaptureVpnService : VpnService() { - private var tunnel: ParcelFileDescriptor? = null - private val parsers = ConcurrentHashMap() - private val taintedFlows = ConcurrentHashMap.newKeySet() - private val flowMetadata = ConcurrentHashMap() - private val flowSessions = ConcurrentHashMap() - private val pendingAdmissionByFlow = ConcurrentHashMap() - private val pendingFlowPayloads = BoundedFlowPayloadBuffer( - MAX_PENDING_PAYLOADS_PER_FLOW, - MAX_PENDING_PAYLOADS_TOTAL, - ) - private val decodedPayloadCount = AtomicLong() - private val observedPayloadBytes = AtomicLong() - private val inspectedPayloadBytes = AtomicLong() - private val reportedTrafficBucket = AtomicLong() - private val parseWarningCount = AtomicLong() - private val droppedParserTaskCount = AtomicLong() - private val unknownPayloadCounts = ConcurrentHashMap() - private val captureChecklist = ScopedCaptureChecklist(REQUIRED_CAPTURE_TYPES) - private val profileAdmissionGate = PlatoonProfilePolicy.AdmissionGate() + private enum class State { IDLE, STARTING, RUNNING, STOPPING } + private val mainHandler = Handler(Looper.getMainLooper()) - private val historyStores = ConcurrentHashMap() + private val stats = CaptureTrafficStats() private lateinit var profileRegistry: PlatoonProfileRegistry - private lateinit var clientServerRegions: ClientServerRegionPreferences - private lateinit var payloadHistoryPreferences: PayloadHistoryPreferences - private lateinit var capturePreferences: CapturePreferences private lateinit var diagnosticsStore: CaptureDiagnosticsStore - private var sessionStartedAt: Instant? = null - private var captureOnce = false + private lateinit var notifications: CaptureNotifications + private lateinit var worker: CaptureWorker + private lateinit var engine: CaptureFlowEngine + private var state = State.IDLE + private var run: CaptureRunTracker? = null + private var tunnel: ParcelFileDescriptor? = null + private var restartCaptureOnce: Boolean? = null + private var destroyed = false + private val captureOnceGraceStop = Runnable { - val captured = captureChecklist.targetCaptured() - if (captureOnce && - CaptureStatus.isRunning && - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS in captured - ) { - val missing = REQUIRED_CAPTURE_TYPES - .minus(captured) - .map(PayloadCatalog::tag) - .joinToString() - stopCapture("Captured Platoon roster; missing $missing after the navigation period") - } - } - private val migrationExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2LegacyImport") + val missing = run?.takeIf { state == State.RUNNING }?.missingAfterRoster() ?: return@Runnable + stop(CaptureNotice.CAPTURE_ONCE_PARTIAL, missing.joinToString()) } - @Volatile - private var captureStartPending = false - private var parserExecutor = createParserExecutor() override fun onCreate() { super.onCreate() profileRegistry = PlatoonProfileRegistry(this) - clientServerRegions = ClientServerRegionPreferences(this) - payloadHistoryPreferences = PayloadHistoryPreferences(this) - capturePreferences = CapturePreferences(this) diagnosticsStore = CaptureDiagnosticsStore(this) - createNotificationChannel() + notifications = CaptureNotifications(this).also { it.createChannel() } + worker = CaptureWorker("GF2CaptureWorker", PARSER_QUEUE_CAPACITY) { error -> + CaptureStatus.report(CaptureNotice.PROCESSING_FAILED, error.javaClass.simpleName) + } + engine = CaptureFlowEngine( + context = this, + worker = worker, + stats = stats, + profileRegistry = profileRegistry, + clientServerRegions = ClientServerRegionPreferences(this), + payloadHistory = PayloadHistoryPreferences(this), + ) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_STOP -> stopCapture() - ACTION_START -> { - captureOnce = intent.getBooleanExtra(EXTRA_CAPTURE_ONCE, false) - CaptureStatus.beginSession(captureOnce) - startCapture() - } + ACTION_START -> start(intent.getBooleanExtra(EXTRA_CAPTURE_ONCE, false)) + ACTION_STOP -> stop() ACTION_CONFIRM_PENDING_PLATOON -> { val token = intent.getStringExtra(EXTRA_PENDING_TOKEN) - val region = GameServerRegion.fromStored( - intent.getStringExtra(EXTRA_SERVER_REGION), - ) + val region = GameServerRegion.fromStored(intent.getStringExtra(EXTRA_SERVER_REGION)) if (token.isNullOrBlank() || region == GameServerRegion.MANUAL) { - CaptureStatus.update("Unable to confirm the pending Platoon") - stopSelf(startId) - } else if (!submitParserTask { - confirmPendingPlatoon(token, region) - if (tunnel == null) stopSelf(startId) - } - ) { - CaptureStatus.update("Unable to queue the pending Platoon confirmation") - stopSelf(startId) + CaptureStatus.report(CaptureNotice.ADMISSION_INVALID) + stopIfIdle(startId) + } else { + runAdmission(startId) { engine.admission.confirm(token, region) } } } ACTION_DISCARD_PENDING_PLATOON -> { val token = intent.getStringExtra(EXTRA_PENDING_TOKEN) - if (!token.isNullOrBlank()) { - if (!submitParserTask { - discardPendingPlatoon(token) - if (tunnel == null) stopSelf(startId) - } - ) { - CaptureStatus.update("Unable to queue the pending Platoon discard") - stopSelf(startId) - } + if (token.isNullOrBlank()) { + stopIfIdle(startId) } else { - stopSelf(startId) + runAdmission(startId) { engine.admission.discard(token) } } } } @@ -144,58 +89,109 @@ class CaptureVpnService : VpnService() { } override fun onRevoke() { - stopCapture() + stop() super.onRevoke() } override fun onDestroy() { - captureStartPending = false - releaseCaptureResources() - if (CaptureStatus.isRunning) CaptureStatus.markStopped() - mainHandler.removeCallbacksAndMessages(null) - migrationExecutor.shutdownNow() - closeAllFlowSessions() + destroyed = true + mainHandler.removeCallbacks(captureOnceGraceStop) + when (state) { + State.IDLE -> worker.shutdown() + // The teardown shuts the worker down once it completes. + State.STOPPING -> Unit + State.STARTING, State.RUNNING -> stop() + } super.onDestroy() } - private fun startCapture() { - if (tunnel != null) { - CaptureStatus.markRunning("Capture is already running") - updateNotification("Capturing selected game traffic") - return + /** Candidates outlive capture, so admission decisions also run while capture is stopped. */ + private fun runAdmission(startId: Int, task: () -> Unit) { + val queued = worker.execute { + try { + task() + } finally { + mainHandler.post { stopIfIdle(startId) } + } } - if (captureStartPending) { - CaptureStatus.update("Preparing capture") - return + if (!queued) stopIfIdle(startId) + } + + private fun stopIfIdle(startId: Int) { + if (state == State.IDLE && !destroyed) stopSelf(startId) + } + + private fun start(captureOnce: Boolean) { + // Every startForegroundService request must be answered with a foreground notification. + notifications.startForeground( + if (state == State.RUNNING) { + R.string.capture_notification_running + } else { + R.string.capture_notification_preparing + }, + ) + when (state) { + State.RUNNING -> { + CaptureStatus.report(CaptureNotice.ALREADY_RUNNING) + return + } + State.STARTING -> { + CaptureStatus.report(CaptureNotice.PREPARING) + return + } + State.STOPPING -> { + restartCaptureOnce = captureOnce + CaptureStatus.markStarting() + return + } + State.IDLE -> Unit } - startInForeground("Preparing capture") - CaptureStatus.update("Preparing capture") + state = State.STARTING + val tracker = CaptureRunTracker( + captureOnce = captureOnce, + onComplete = { completed -> + mainHandler.post { + if (run === completed && state == State.RUNNING) { + stop(CaptureNotice.CAPTURE_ONCE_COMPLETE) + } + } + }, + onTargetRosterCaptured = { captured -> + mainHandler.post { + if (run === captured) { + mainHandler.removeCallbacks(captureOnceGraceStop) + mainHandler.postDelayed(captureOnceGraceStop, CAPTURE_ONCE_GRACE_MILLIS) + } + } + }, + ) + run = tracker + CaptureStatus.beginSession(captureOnce) + CaptureStatus.markStarting() if (!NativeCaptureBridge.isAvailable) { - failStart("Protocol parser ready; native forwarding core is not integrated yet") + stop(CaptureNotice.NATIVE_UNAVAILABLE) return } - - captureStartPending = true - migrationExecutor.execute { + worker.execute { val migration = runCatching { profileRegistry.ensureInitialized().forEach { profile -> - val scope = PlatoonStorageScope(profile.storageId) - PlatoonRepository(this, scope).reconcileRetainedCsvFiles() + PlatoonRepository(this, PlatoonStorageScope(profile.storageId)) + .reconcileRetainedCsvFiles() } } mainHandler.post { - if (!captureStartPending) return@post - captureStartPending = false - migration.fold( - onSuccess = { startTunnel() }, - onFailure = { failStart("Unable to import previous Platoon history") }, - ) + if (run !== tracker || state != State.STARTING) return@post + if (migration.isSuccess) { + establish(tracker) + } else { + stop(CaptureNotice.HISTORY_IMPORT_FAILED) + } } } } - private fun startTunnel() { + private fun establish(tracker: CaptureRunTracker) { val builder = Builder() .setSession("GF2logger") .setMtu(VPN_MTU) @@ -204,749 +200,99 @@ class CaptureVpnService : VpnService() { .addAddress(VPN_IPV6_ADDRESS, VPN_IPV6_PREFIX_LENGTH) .addRoute("::", 0) - val installedTargets = mutableListOf() - SupportedGamePackages.all.forEach { targetPackage -> + val installedTargets = SupportedGamePackages.all.filter { targetPackage -> try { builder.addAllowedApplication(targetPackage) - installedTargets += targetPackage + true } catch (_: PackageManager.NameNotFoundException) { - // A user may install either publisher's client or both. + false // A user may install either publisher's client or both. } } if (installedTargets.isEmpty()) { - failStart("Install a supported HaoPlay or Darkwinter client first") + stop(CaptureNotice.NO_SUPPORTED_CLIENT) return } val descriptor = try { builder.establish() } catch (error: Exception) { - failStart("Unable to establish VPN: ${error.message ?: error.javaClass.simpleName}") + stop(CaptureNotice.VPN_ESTABLISH_FAILED, error.message ?: error.javaClass.simpleName) return } - if (descriptor == null) { - failStart("VPN permission was revoked") + stop(CaptureNotice.VPN_PERMISSION_REVOKED) return } tunnel = descriptor - if (parserExecutor.isShutdown) { - parserExecutor = createParserExecutor() - } - parsers.clear() - taintedFlows.clear() - flowMetadata.clear() - closeAllFlowSessions() - pendingFlowPayloads.clear() - decodedPayloadCount.set(0) - observedPayloadBytes.set(0) - inspectedPayloadBytes.set(0) - reportedTrafficBucket.set(0) - parseWarningCount.set(0) - droppedParserTaskCount.set(0) - unknownPayloadCounts.clear() - captureChecklist.clear() - profileAdmissionGate.clear() - mainHandler.removeCallbacks(captureOnceGraceStop) - sessionStartedAt = Instant.now() - - CaptureStatus.markRunning("Starting native capture") - val started = try { - NativeCaptureBridge.start( - descriptor.fd, - this, - object : NativeCaptureBridge.PayloadListener { - override fun onFlowOpened( - flowId: Long, - protocol: Int, - localAddress: String, - localPort: Int, - remoteAddress: String, - remotePort: Int, - ) { - enqueueFlowOpened( - flowId, - protocol, - localAddress, - localPort, - remoteAddress, - remotePort, - ) - } - - override fun onPayload(flowId: Long, isSent: Boolean, payload: ByteArray) { - enqueuePayload(flowId, isSent, payload) - } + stats.begin() + // Queued before the native thread exists, so it precedes every native callback. + worker.execute { engine.beginSession(tracker) } - override fun onFlowClosed(flowId: Long) { - enqueueFlowClosed(flowId) - } - - override fun onTraffic( - sentBytes: Long, - receivedBytes: Long, - inspectedBytes: Long, - ) { - recordTraffic(sentBytes + receivedBytes, inspectedBytes) - } - - override fun onCaptureStopped() { - handleNativeCaptureStopped() - } - }, - ) - } catch (error: Exception) { - false + val listener = engine.nativeListener { + mainHandler.post { + if (run === tracker && state == State.RUNNING) stop(CaptureNotice.STOPPED_UNEXPECTEDLY) + } } + val started = runCatching { NativeCaptureBridge.start(descriptor.fd, this, listener) } + .getOrDefault(false) if (!started) { - failStart("Native forwarding core failed to start") + stop(CaptureNotice.NATIVE_START_FAILED) return } - CaptureStatus.markRunning("Capturing only ${installedTargets.joinToString()}") - updateNotification("Capturing selected game traffic") - } - - private fun enqueuePayload(flowId: Long, isSent: Boolean, payload: ByteArray) { - if (payload.isEmpty()) return - if (isSent) return - if (flowId in taintedFlows) return - if (!submitParserTask { - if (flowId in taintedFlows) return@submitParserTask - val parser = parsers.computeIfAbsent(flowId) { Gfl2StreamParser() } - processEvents(flowId, parser.accept(payload)) - }) { - // A missing TCP chunk makes every later byte offset unreliable. - // Keep this flow quarantined until native closure instead of - // feeding a corrupted stream into the framing parser. - taintedFlows += flowId - } - } - - private fun enqueueFlowOpened( - flowId: Long, - protocol: Int, - localAddress: String, - localPort: Int, - remoteAddress: String, - remotePort: Int, - ) { - submitParserTask { - CaptureFlowStateCleanup.registerUnlessQuarantined( - flowId = flowId, - value = CaptureFlowMetadata( - protocol = protocol, - localAddress = localAddress, - localPort = localPort, - remoteAddress = remoteAddress, - remotePort = remotePort, - ownerPackage = CaptureFlowOwnerResolver.resolve( - this, - protocol, - localAddress, - localPort, - remoteAddress, - remotePort, - ), - ), - metadata = flowMetadata, - quarantinedFlows = taintedFlows, - ) - } - } - - private fun enqueueFlowClosed(flowId: Long) { - if (!submitParserTask { - val metadata = flowMetadata[flowId] - if (taintedFlows.remove(flowId)) { - CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) - closeFlowSession(flowId) - return@submitParserTask - } - val parser = CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) - if (parser != null) { - processEvents( - flowId = flowId, - events = parser.finish(), - metadata = metadata, - flowEnded = true, - ) - } - closeFlowSession(flowId) - } - ) { - CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) - closeFlowSession(flowId) - // Earlier queued chunks may still run even though the close task was rejected. - // Keep the flow tainted until the capture session resets all parser state. - taintedFlows += flowId - } - } - - private fun submitParserTask(task: () -> Unit): Boolean = try { - parserExecutor.execute { - runCatching(task).onFailure { - CaptureStatus.update("Protocol processing failed: ${it.javaClass.simpleName}") - } - } - true - } catch (_: RejectedExecutionException) { - val dropped = droppedParserTaskCount.incrementAndGet() - CaptureStatus.update("Parser overloaded; dropped $dropped queued chunks") - false - } - - private fun processEvents( - flowId: Long, - events: List, - metadata: CaptureFlowMetadata? = flowMetadata[flowId], - flowEnded: Boolean = false, - ) { - val warnings = events.filterIsInstance() - if (warnings.isNotEmpty()) parseWarningCount.addAndGet(warnings.size.toLong()) - - val decoded = events.filterIsInstance() - events.filterIsInstance().forEach { event -> - unknownPayloadCounts.computeIfAbsent(event.payloadType) { AtomicLong() }.incrementAndGet() - } - decoded.forEachIndexed { index, event -> - if (event.value.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { - val profile = event.value.data as? PlatoonProfileData - if (PlatoonProfilePolicy.isValid(profile)) { - requireNotNull(profile) - identifyFlow(flowId, metadata, profile) - } - } - val session = flowSessions[flowId] - if (session == null) { - retainPendingPayload(flowId, event.value) - return@forEachIndexed - } - routeConfirmedPayload( - session, - event.value, - flowEnded = flowEnded && index == decoded.lastIndex, - ) - } - if (decoded.isNotEmpty()) decodedPayloadCount.addAndGet(decoded.size.toLong()) + state = State.RUNNING + CaptureStatus.markRunning(installedTargets) + notifications.update(R.string.capture_notification_running) } - private fun identifyFlow( - flowId: Long, - metadata: CaptureFlowMetadata?, - data: PlatoonProfileData, - ) { - if (pendingFlowPayloads.isRejected(flowId)) { - quarantineFlow(flowId) - CaptureStatus.update("Discarded an identified flow whose pre-identity buffer overflowed") - return - } - val ownerPackage = metadata?.ownerPackage - if (ownerPackage !in SupportedGamePackages.all) { - quarantineFlow(flowId) - CaptureStatus.update("Detected a Platoon profile, but its game client could not be verified") - return - } - val verifiedOwnerPackage = requireNotNull(ownerPackage) - val client = requireNotNull(PlatoonClient.fromPackage(verifiedOwnerPackage)) - pendingAdmissionByFlow[flowId]?.let { token -> - val pending = PendingPlatoonAdmissionStore.summary(token) - if ( - pending?.ownerPackage == verifiedOwnerPackage && - pending.profile.platoonId == data.platoonId - ) { + private fun stop(notice: CaptureNotice? = null, vararg args: String) { + mainHandler.removeCallbacks(captureOnceGraceStop) + restartCaptureOnce = null + val lastNotice = CaptureStatus.snapshot().notice + CaptureStatus.markStopped(notice, *args) + notifications.remove() + when (state) { + State.IDLE -> { + if (!destroyed) stopSelf() return } - discardPendingPlatoon(token) - quarantineFlow(flowId) - CaptureStatus.update("Discarded a flow whose pending Platoon identity changed") - return - } - val knownProfiles = profileRegistry.findByClientAndPlatoonId( - client, - data.platoonId.toLong(), - ) - val configuredRegion = clientServerRegions.configured(verifiedOwnerPackage) - val known = when { - knownProfiles.size == 1 -> knownProfiles.single() - knownProfiles.size > 1 -> knownProfiles.singleOrNull { - it.serverRegion == configuredRegion - } - else -> null - } - if (known == null) { - beginPendingPlatoon(flowId, verifiedOwnerPackage, data) - return - } - val current = flowSessions[flowId] - if (current != null && current.profile.storageId != known.storageId) { - quarantineFlow(flowId) - CaptureStatus.update("Discarded a flow whose Platoon identity changed") - return - } - val profile = runCatching { - profileRegistry.updateObserved(known.storageId, data) - }.getOrElse { - quarantineFlow(flowId) - CaptureStatus.update("Unable to isolate the detected Platoon") - return - } - if (current?.profile?.storageId == profile.storageId) { - markRequiredPayloadCaptured( - profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - ) - return - } - val session = replaceFlowSession(flowId, profile) - pendingFlowPayloads.take(flowId).forEach { pending -> - routeConfirmedPayload(session, pending) - } - CaptureStatus.update( - "Detected ${profile.platoonName.take(40)} (${profile.platoonId}) via " + - profile.client.displayName, - ) - markRequiredPayloadCaptured( - profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - ) - } - - private fun retainPendingPayload(flowId: Long, payload: ParsedPayload) { - pendingAdmissionByFlow[flowId]?.let { token -> - when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, payload)) { - PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit - PendingPlatoonAdmissionStore.OfferResult.Claimed -> - CaptureStatus.update("Waiting for the pending Platoon confirmation to finish") - PendingPlatoonAdmissionStore.OfferResult.Missing -> { - pendingAdmissionByFlow.remove(flowId, token) - quarantineFlow(flowId) - } - is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { - result.rejectedFlowIds.forEach { rejectedFlow -> - pendingAdmissionByFlow.remove(rejectedFlow, token) - quarantineFlow(rejectedFlow) - } - CaptureStatus.update("Discarded an oversized pending Platoon capture") - } - } - return - } - when (pendingFlowPayloads.offer(flowId, payload)) { - BoundedFlowPayloadBuffer.OfferResult.OVERFLOW -> - CaptureStatus.update("Discarded an unidentified Platoon flow that exceeded its buffer") - BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, - BoundedFlowPayloadBuffer.OfferResult.REJECTED, - -> Unit + State.STOPPING -> return + State.STARTING, State.RUNNING -> Unit } - } - private fun beginPendingPlatoon( - flowId: Long, - ownerPackage: String, - data: PlatoonProfileData, - ) { - val result = PendingPlatoonAdmissionStore.begin(ownerPackage, data, flowId) - val token = result.token - if (token == null) { - result.rejectedFlowIds.forEach(::quarantineFlow) - CaptureStatus.update("Discarded a new Platoon because the confirmation queue is full") - return - } - pendingAdmissionByFlow[flowId] = token - pendingFlowPayloads.take(flowId).forEach { pending -> - when (val offered = PendingPlatoonAdmissionStore.offer(token, flowId, pending)) { - PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit - is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { - offered.rejectedFlowIds.forEach { rejected -> - pendingAdmissionByFlow.remove(rejected, token) - quarantineFlow(rejected) + state = State.STOPPING + run = null + val descriptor = tunnel + tunnel = null + worker.execute { + try { + // Joins the native thread; no callback can enqueue work after this returns. + NativeCaptureBridge.stop() + runCatching { descriptor?.close() } + } finally { + // Runs after every chunk the native thread queued before the join. + worker.execute { + try { + engine.endSession() + val finalNotice = CaptureStatus.snapshot().notice ?: lastNotice + stats.finish(finalNotice?.format(this).orEmpty())?.let(diagnosticsStore::save) + } finally { + mainHandler.post(::onTeardownComplete) } - CaptureStatus.update("Discarded an oversized pending Platoon capture") - return - } - PendingPlatoonAdmissionStore.OfferResult.Claimed, - PendingPlatoonAdmissionStore.OfferResult.Missing, - -> { - pendingAdmissionByFlow.remove(flowId, token) - quarantineFlow(flowId) - return } } } - CaptureStatus.update("New Platoon detected; return to GF2logger and choose its server") } - private fun confirmPendingPlatoon(token: String, region: GameServerRegion) { - val claim = PendingPlatoonAdmissionStore.claim(token) ?: return - val ownerPackage = claim.summary.ownerPackage - if (region !in clientServerRegions.allowed(ownerPackage)) { - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("The selected server does not belong to the verified client") - return - } - val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) - val existing = profileRegistry.findByIdentity( - client, - region, - claim.summary.profile.platoonId.toLong(), - ) - if (!profileAdmissionGate.canAdmit(ownerPackage, existing != null)) { - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("Start a new capture before adding another Platoon for this client") + private fun onTeardownComplete() { + state = State.IDLE + if (destroyed) { + worker.shutdown() return } - val previousRegion = clientServerRegions.stored(ownerPackage) - val profile = runCatching { - clientServerRegions.set(ownerPackage, region) - profileRegistry.upsertDetected(ownerPackage, region, claim.summary.profile).also { - check(profileRegistry.setActive(it.storageId)) { - "Unable to select the confirmed Platoon" - } - } - }.getOrElse { error -> - runCatching { - if (previousRegion == null) { - clientServerRegions.clear(ownerPackage) - } else { - clientServerRegions.set(ownerPackage, previousRegion) - } - }.onFailure(error::addSuppressed) - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("Unable to create the confirmed Platoon profile") - return - } - if (existing == null) profileAdmissionGate.markAdmitted(ownerPackage) - - val sessions = mutableMapOf() - val routed = runCatching { - claim.flowIds.forEach { flowId -> - val session = replaceFlowSession(flowId, profile) - sessions[flowId] = session - if (flowId !in claim.endedFlowIds && flowMetadata.containsKey(flowId)) { - flowSessions[flowId] = session - } - } - val lastPayloadIndexByFlow = claim.payloads - .withIndex() - .associate { it.value.flowId to it.index } - claim.payloads.forEachIndexed { index, buffered -> - val session = sessions.getOrPut(buffered.flowId) { - replaceFlowSession(buffered.flowId, profile) - } - routeConfirmedPayload( - session = session, - payload = buffered.payload, - flowEnded = buffered.flowId in claim.endedFlowIds && - lastPayloadIndexByFlow[buffered.flowId] == index, - ) - } - } - if (routed.isFailure) { - sessions.forEach { (flowId, session) -> - flowSessions.remove(flowId, session) - runCatching(session::close) - } - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("Unable to apply the confirmed Platoon packets") - return - } - claim.endedFlowIds.forEach { flowId -> - sessions.remove(flowId)?.let { session -> - flowSessions.remove(flowId, session) - session.close() - } - } - PendingPlatoonAdmissionStore.complete(token).forEach { flowId -> - pendingAdmissionByFlow.remove(flowId, token) - } - CaptureStatus.update( - "Confirmed ${profile.platoonName.take(40)} (${profile.platoonId}) via " + - profile.client.displayName, - ) - } - - private fun discardPendingPlatoon(token: String) { - PendingPlatoonAdmissionStore.discard(token).forEach { flowId -> - pendingAdmissionByFlow.remove(flowId, token) - if (flowMetadata.containsKey(flowId) || parsers.containsKey(flowId)) { - quarantineFlow(flowId) - } - } - CaptureStatus.update("Discarded the unconfirmed Platoon packets") - } - - private fun replaceFlowSession( - flowId: Long, - profile: dev.gf2log.app.management.PlatoonProfile, - ): PlatoonCaptureSession { - flowSessions.remove(flowId)?.close() - return PlatoonCaptureSession( - context = this, - profile = profile, - onRosterCaptured = ::markRosterCaptured, - onStatus = CaptureStatus::update, - ).also { flowSessions[flowId] = it } - } - - /** Permanently blocks management routing for this flow until native closure. */ - private fun quarantineFlow(flowId: Long) { - taintedFlows += flowId - flowSessions.remove(flowId)?.close() - pendingFlowPayloads.reject(flowId) - } - - private fun routePayload( - session: PlatoonCaptureSession, - payload: ParsedPayload, - flowEnded: Boolean = false, - ) { - val routed = session.dispatch(payload, flowEnded) - routed.activity?.onSuccess { accepted -> - if (accepted) { - markRequiredPayloadCaptured( - session.profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, - ) - } - }?.onFailure { CaptureStatus.update("Unable to update Platoon activity history") } - routed.updates?.onSuccess { accepted -> - if (accepted) { - markRequiredPayloadCaptured( - session.profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, - ) - } - }?.onFailure { CaptureStatus.update("Unable to update exact Platoon history") } - routed.members.onSuccess { saved -> - if (saved != null) { - CaptureStatus.update( - "Saved ${saved.rowCount} members for ${session.profile.platoonName}", - ) - } - }.onFailure { CaptureStatus.update("Unable to save Platoon CSV") } - } - - private fun routeConfirmedPayload( - session: PlatoonCaptureSession, - payload: ParsedPayload, - flowEnded: Boolean = false, - ) { - saveHistoryOnly(session.profile.storageId, payload) - routePayload(session, payload, flowEnded) - if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { - markRequiredPayloadCaptured( - session.profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - ) - } - } - - private fun saveHistoryOnly(storageId: String, payload: ParsedPayload) { - if (!payloadHistoryPreferences.isEnabled(payload.payloadType)) return - val historyStore = historyStores.computeIfAbsent(storageId) { - val scope = PlatoonStorageScope(storageId) - CaptureHistoryStore( - File(scope.rootDirectory(this), CaptureHistoryStore.HISTORY_DIRECTORY), - ) - } - runCatching { historyStore.save(payload) } - .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } - } - - private fun closeFlowSession(flowId: Long) { - pendingAdmissionByFlow.remove(flowId)?.let { token -> - PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) - } - pendingFlowPayloads.remove(flowId) - flowSessions.remove(flowId)?.close() - } - - private fun closeAllFlowSessions() { - flowSessions.values.forEach { runCatching { it.close() } } - flowSessions.clear() - } - - private fun maybeStopCaptureOnce() { - if (!captureOnce || !CaptureStatus.isRunning) return - if (!captureChecklist.targetComplete()) return - mainHandler.removeCallbacks(captureOnceGraceStop) - mainHandler.post { - if (captureOnce && - CaptureStatus.isRunning && - captureChecklist.targetComplete() - ) { - stopCapture("Captured Platoon roster, activity, and updates") - } - } - } - - private fun markRosterCaptured(storageId: String) { - markRequiredPayloadCaptured(storageId, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) - if (captureOnce && captureChecklist.targetScopeId() == storageId) { - mainHandler.removeCallbacks(captureOnceGraceStop) - mainHandler.postDelayed(captureOnceGraceStop, CAPTURE_ONCE_GRACE_MILLIS) - } - } - - private fun markRequiredPayloadCaptured(storageId: String, payloadType: Int) { - captureChecklist.mark(storageId, payloadType, chooseTarget = captureOnce) - if (!captureOnce || captureChecklist.targetScopeId() == storageId) { - CaptureStatus.markUsefulPayload(payloadType) - } - maybeStopCaptureOnce() - } - - private fun recordTraffic(observed: Long, inspected: Long) { - observedPayloadBytes.set(observed) - inspectedPayloadBytes.set(inspected) - val bucket = observed / TRAFFIC_REPORT_BYTES - val previousBucket = reportedTrafficBucket.get() - if (bucket > previousBucket && reportedTrafficBucket.compareAndSet(previousBucket, bucket)) { - CaptureStatus.update( - "Forwarded ${observed / 1024} KiB; inspected ${inspected / 1024} KiB; " + - "decoded ${decodedPayloadCount.get()} payloads; " + - "warnings ${parseWarningCount.get()}; dropped ${droppedParserTaskCount.get()}", - ) - } - } - - private fun handleNativeCaptureStopped() { - mainHandler.post { - if (!CaptureStatus.isRunning) return@post - tunnel?.close() - tunnel = null - drainParserTasks() - parsers.clear() - flowMetadata.clear() - closeAllFlowSessions() - pendingFlowPayloads.clear() - CaptureStatus.markStopped("Capture stopped unexpectedly; press Prepare capture to retry") - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } - - private fun failStart(message: String) { - captureStartPending = false - releaseCaptureResources() - CaptureStatus.markStopped(message) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - private fun stopCapture(message: String = "") { - captureStartPending = false - releaseCaptureResources() - if (message.isBlank()) CaptureStatus.markStopped() else CaptureStatus.markStopped(message) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - private fun releaseCaptureResources() { - NativeCaptureBridge.stop() - tunnel?.close() - tunnel = null - drainParserTasks() - parsers.clear() - taintedFlows.clear() - flowMetadata.clear() - closeAllFlowSessions() - pendingAdmissionByFlow.forEach { (flowId, token) -> - PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) - } - pendingAdmissionByFlow.clear() - pendingFlowPayloads.clear() - saveDiagnostics() - } - - private fun saveDiagnostics() { - if (sessionStartedAt == null) return - diagnosticsStore.save( - CaptureDiagnosticsStore.Diagnostics( - startedAt = sessionStartedAt, - stoppedAt = Instant.now(), - forwardedBytes = observedPayloadBytes.get(), - inspectedBytes = inspectedPayloadBytes.get(), - decodedPayloads = decodedPayloadCount.get(), - warnings = parseWarningCount.get(), - droppedChunks = droppedParserTaskCount.get(), - unknownPayloads = unknownPayloadCounts.entries - .sortedBy { it.key } - .joinToString { "${it.key}:${it.value.get()}" }, - finalStatus = CaptureStatus.read(), - ), - ) - sessionStartedAt = null - } - - private fun drainParserTasks() { - parserExecutor.shutdown() - val completed = try { - parserExecutor.awaitTermination(PARSER_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - false - } - if (!completed) { - val dropped = parserExecutor.shutdownNow().size - droppedParserTaskCount.addAndGet(dropped.toLong()) - } - } - - private fun createParserExecutor() = ThreadPoolExecutor( - 1, - 1, - 0, - TimeUnit.MILLISECONDS, - ArrayBlockingQueue(PARSER_QUEUE_CAPACITY), - { runnable -> Thread(runnable, "GF2ProtocolParser") }, - ThreadPoolExecutor.AbortPolicy(), - ) - - private fun startInForeground(content: String) { - val notification = buildNotification(notificationContent(content)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } - - private fun updateNotification(content: String) { - getSystemService(NotificationManager::class.java) - .notify( - NOTIFICATION_ID, - buildNotification(notificationContent(content)), - ) - } - - private fun notificationContent(content: String): String = - if (capturePreferences.detailedNotifications) { - content - } else { - getString(R.string.notification_capture_active) - } - - private fun buildNotification(content: String): Notification = - Notification.Builder(this, NOTIFICATION_CHANNEL) - .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(getString(R.string.app_name)) - .setContentText(content) - .setOngoing(true) - .build() - - private fun createNotificationChannel() { - val channel = NotificationChannel( - NOTIFICATION_CHANNEL, - getString(R.string.notification_channel), - NotificationManager.IMPORTANCE_LOW, - ) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + val restart = restartCaptureOnce + restartCaptureOnce = null + if (restart != null) start(restart) else stopSelf() } companion object { @@ -957,24 +303,12 @@ class CaptureVpnService : VpnService() { const val EXTRA_CAPTURE_ONCE = "capture_once" const val EXTRA_PENDING_TOKEN = "pending_platoon_token" const val EXTRA_SERVER_REGION = "pending_server_region" - private const val NOTIFICATION_CHANNEL = "capture" - private const val NOTIFICATION_ID = 1 private const val VPN_ADDRESS = "10.77.0.1" private const val VPN_PREFIX_LENGTH = 30 private const val VPN_IPV6_ADDRESS = "fd77:1::1" private const val VPN_IPV6_PREFIX_LENGTH = 120 private const val VPN_MTU = 1500 private const val PARSER_QUEUE_CAPACITY = 256 - private const val PARSER_DRAIN_TIMEOUT_SECONDS = 3L - private const val TRAFFIC_REPORT_BYTES = 64 * 1024 private const val CAPTURE_ONCE_GRACE_MILLIS = 60_000L - private const val MAX_PENDING_PAYLOADS_PER_FLOW = 32 - private const val MAX_PENDING_PAYLOADS_TOTAL = 128 - private val REQUIRED_CAPTURE_TYPES = setOf( - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, - ) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt new file mode 100644 index 0000000..69e9769 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt @@ -0,0 +1,51 @@ +package dev.gf2log.app.capture + +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicInteger + +/** + * The single thread that owns every parser, flow, session, and admission structure. + * + * Native callbacks share a bounded chunk budget and are rejected when it is exhausted. Lifecycle + * and admission tasks bypass that budget, so teardown, confirm, and discard are never dropped + * while the worker is open. Tasks run in submission order. + */ +internal class CaptureWorker( + name: String, + private val chunkCapacity: Int, + private val onFailure: (Throwable) -> Unit, +) { + private val executor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, name) } + private val queuedChunks = AtomicInteger() + + init { + require(chunkCapacity > 0) + } + + fun submitChunk(task: () -> Unit): Boolean { + if (queuedChunks.incrementAndGet() > chunkCapacity) { + queuedChunks.decrementAndGet() + return false + } + val queued = execute { + try { + task() + } finally { + queuedChunks.decrementAndGet() + } + } + if (!queued) queuedChunks.decrementAndGet() + return queued + } + + fun execute(task: () -> Unit): Boolean = try { + executor.execute { runCatching(task).onFailure(onFailure) } + true + } catch (_: RejectedExecutionException) { + false + } + + /** Already queued tasks still run; later submissions are rejected. */ + fun shutdown() = executor.shutdown() +} diff --git a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt index b5f2f5b..891434a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -1,137 +1,78 @@ package dev.gf2log.app.capture -import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.app.history.AtomicFiles import dev.gf2log.protocol.GuildMembersCsv -import dev.gf2log.protocol.model.GuildMembersData +import dev.gf2log.protocol.LogTime import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload -import java.io.BufferedWriter import java.io.File -import java.io.FileOutputStream -import java.io.OutputStreamWriter -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.time.Clock import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +/** + * Writes each completed logical roster dataset once, deduplicated by UID. The stream parser + * already merges continuation frames, so a roster payload that does not complete its logical + * payload was cut short by another payload or by flow closure. Like the reference logger, such a + * boundary ends the dataset; it is discarded instead of being merged with a later, unrelated roster. + * An empty `.partial` file reserves the final name while the CSV is written. + */ class GuildMembersCsvWriter( private val outputDirectory: File, private val clock: Clock = Clock.systemUTC(), - private val publisher: (File, File) -> Unit = ::publishCompletedFile, + private val publisher: (File, File) -> Unit = AtomicFiles::move, private val onBatchClosed: (CompletedBatch) -> Unit = {}, -) : AutoCloseable { - private var activeBatch: Batch? = null - +) { @Synchronized - fun accept(payload: ParsedPayload, flowEnded: Boolean = false): SaveResult? { - if (payload.payloadType != Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) { - closeActiveBatch(completed = false) - return null - } - + fun accept(payload: ParsedPayload): SaveResult? { val data = payload.data as? GuildMembersData ?: return null - if (!GuildMembersCsv.hasValidMemberBounds(data.members)) { - closeActiveBatch(completed = false) - error("Platoon roster exceeds the member or name limit") - } - var batch = activeBatch - if (batch == null || (batch.previousMessageId != 0 && batch.previousMessageId != payload.messageId)) { - closeActiveBatch(completed = false) - batch = openBatch() - activeBatch = batch - } - if ((batch.members.keys + data.members.map(GuildMember::uid)).distinct().size > - GuildMembersCsv.MAX_ROSTER_MEMBERS - ) { - closeActiveBatch(completed = false) - error("Platoon roster exceeds the member limit") - } - - data.members.forEach { member -> - batch.writer.appendLine(GuildMembersCsv.row(member, batch.logTime)) - batch.members[member.uid] = member - batch.rows += 1 + check(GuildMembersCsv.hasValidMemberBounds(data.members)) { + "Platoon roster exceeds the member or name limit" } - batch.writer.flush() - batch.previousMessageId = payload.messageId + if (payload.messageId == 0 || !payload.isEndOfMessage) return null + val members = linkedMapOf() + data.members.forEach { member -> members[member.uid] = member } + if (members.isEmpty()) return null + return publish(members.values.toList()) + } - if (payload.messageId != 0 && payload.isEndOfMessage) { - return closeActiveBatch(completed = true)?.let { - SaveResult(it.file, batch.rows) + private fun publish(members: List): SaveResult { + val batch = reserve(Instant.now(clock), members) + try { + batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.appendLine(GuildMembersCsv.HEADER) + members.forEach { member -> + writer.appendLine(GuildMembersCsv.row(member, batch.completed.logTime)) + } } - } else if (flowEnded) { - closeActiveBatch(completed = false) + publishWithRetry(batch.workingFile, batch.completed.file) + } catch (error: Exception) { + // Structured ingestion still gets the completed batch; the caller sees the failure. + runCatching { onBatchClosed(batch.completed) } + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error } - return null - } - - @Synchronized - override fun close() { - closeActiveBatch(completed = false) + onBatchClosed(batch.completed) + return SaveResult(batch.completed.file, members.size) } - private fun openBatch(): Batch { - outputDirectory.mkdirs() - val instant = Instant.now(clock) + private fun reserve(instant: Instant, members: List): Reservation { + check(outputDirectory.isDirectory || outputDirectory.mkdirs()) { + "Unable to create the roster directory" + } val filenameStem = "gf2log_platoonmembers_${FILE_TIME_FORMAT.format(instant)}" - val finalFile = uniqueFile(filenameStem) - val workingFile = File(finalFile.parentFile, "${finalFile.name}.partial") - val writer = BufferedWriter( - OutputStreamWriter(FileOutputStream(workingFile), Charsets.UTF_8), - ) - writer.appendLine(GuildMembersCsv.HEADER) - writer.flush() - return Batch(workingFile, finalFile, LOG_TIME_FORMAT.format(instant), writer) - } - - private fun uniqueFile(filenameStem: String): File { var suffix = 1 - var candidate = File(outputDirectory, "$filenameStem.csv") - while (candidate.exists() || File(candidate.parentFile, "${candidate.name}.partial").exists()) { - suffix += 1 - candidate = File(outputDirectory, "${filenameStem}_$suffix.csv") - } - return candidate - } - - private fun closeActiveBatch(completed: Boolean): CompletedBatch? { - val batch = activeBatch ?: return null - batch.writer.close() - if (completed && batch.members.isNotEmpty()) { - val result = CompletedBatch( - file = batch.finalFile, - logTime = batch.logTime, - members = batch.members.values.toList(), - ) - try { - writeCanonicalRoster(batch) - publishWithRetry(batch.workingFile, batch.finalFile) - } catch (error: Exception) { - activeBatch = null - runCatching { onBatchClosed(result) } - .exceptionOrNull() - ?.let(error::addSuppressed) - throw error - } - activeBatch = null - onBatchClosed(result) - return result - } else { - activeBatch = null - batch.workingFile.delete() - } - return null - } - - private fun writeCanonicalRoster(batch: Batch) { - batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.appendLine(GuildMembersCsv.HEADER) - batch.members.values.forEach { member -> - writer.appendLine(GuildMembersCsv.row(member, batch.logTime)) + while (true) { + val name = if (suffix == 1) "$filenameStem.csv" else "${filenameStem}_$suffix.csv" + val finalFile = File(outputDirectory, name) + val workingFile = File(outputDirectory, "$name.partial") + if (!finalFile.exists() && workingFile.createNewFile()) { + return Reservation(workingFile, CompletedBatch(finalFile, LogTime.format(instant), members)) } + suffix += 1 } } @@ -153,6 +94,7 @@ class GuildMembersCsvWriter( } } + /** [rowCount] equals the number of distinct members written to [file]. */ data class SaveResult(val file: File, val rowCount: Int) data class CompletedBatch( @@ -161,36 +103,13 @@ class GuildMembersCsvWriter( val members: List, ) - private data class Batch( - val workingFile: File, - val finalFile: File, - val logTime: String, - val writer: BufferedWriter, - val members: LinkedHashMap = linkedMapOf(), - var previousMessageId: Int = -1, - var rows: Int = 0, - ) + private class Reservation(val workingFile: File, val completed: CompletedBatch) companion object { private const val PUBLISH_ATTEMPTS = 2 - private fun publishCompletedFile(workingFile: File, finalFile: File) { - try { - Files.move( - workingFile.toPath(), - finalFile.toPath(), - StandardCopyOption.ATOMIC_MOVE, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(workingFile.toPath(), finalFile.toPath()) - } - } - - val FILE_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter + private val FILE_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter .ofPattern("yyyyMMdd'T'HHmmss'Z'") .withZone(ZoneOffset.UTC) - val LOG_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter - .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") - .withZone(ZoneOffset.UTC) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/MainThread.kt b/app/src/main/java/dev/gf2log/app/capture/MainThread.kt new file mode 100644 index 0000000..4e36241 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/MainThread.kt @@ -0,0 +1,29 @@ +package dev.gf2log.app.capture + +import android.os.Handler +import android.os.Looper +import androidx.annotation.VisibleForTesting + +/** Delivers capture listener callbacks on the main looper. */ +internal object MainThread { + /** JVM unit tests have no Android looper; they install a direct executor instead. */ + @VisibleForTesting + @Volatile + var testExecutor: ((() -> Unit) -> Unit)? = null + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + fun post(block: () -> Unit) { + testExecutor?.invoke(block) ?: handler.post(block) + } + + /** Runs [block] synchronously when already on the main thread, otherwise posts it. */ + fun runOrPost(block: () -> Unit) { + val executor = testExecutor + when { + executor != null -> executor(block) + Looper.myLooper() == Looper.getMainLooper() -> block() + else -> handler.post(block) + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt b/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt index 4c114af..6891016 100644 --- a/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt +++ b/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt @@ -13,7 +13,8 @@ object NativeCaptureBridge { remotePort: Int, ) - fun onPayload(flowId: Long, isSent: Boolean, payload: ByteArray) + /** Receives server-to-client TCP payload only; outgoing bytes stay native. */ + fun onPayload(flowId: Long, payload: ByteArray) fun onFlowClosed(flowId: Long) diff --git a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt index f0cedaf..68119c6 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -1,10 +1,12 @@ package dev.gf2log.app.capture +import androidx.annotation.VisibleForTesting import dev.gf2log.app.SupportedGamePackages import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData import java.time.Instant import java.util.UUID +import java.util.concurrent.CopyOnWriteArraySet /** * Holds unconfirmed Platoon evidence only in process memory. @@ -60,6 +62,21 @@ internal object PendingPlatoonAdmissionStore { private val candidates = linkedMapOf() private val tokenByIdentity = mutableMapOf() + private var totalPayloads = 0 + private val listeners = CopyOnWriteArraySet<(List) -> Unit>() + + /** + * [listener] receives the visible candidates on the main looper: once immediately, then + * whenever a candidate appears, changes identity, is claimed or released, or is removed. + */ + fun addListener(listener: (List) -> Unit) { + listeners += listener + MainThread.runOrPost { if (listener in listeners) listener(summaries()) } + } + + fun removeListener(listener: (List) -> Unit) { + listeners -= listener + } @Synchronized fun begin( @@ -74,7 +91,10 @@ internal object PendingPlatoonAdmissionStore { tokenByIdentity[key]?.let { token -> val existing = candidates[token] if (existing != null && !existing.claimed) { - existing.profile = profile + if (existing.profile != profile) { + existing.profile = profile + notifyChanged() + } existing.flowIds += flowId return BeginResult(token) } @@ -92,6 +112,7 @@ internal object PendingPlatoonAdmissionStore { flowIds = linkedSetOf(flowId), ) tokenByIdentity[key] = token + notifyChanged() return BeginResult(token) } @@ -101,12 +122,13 @@ internal object PendingPlatoonAdmissionStore { if (candidate.claimed) return OfferResult.Claimed if ( candidate.payloads.size >= MAX_PAYLOADS_PER_CANDIDATE || - candidates.values.sumOf { it.payloads.size } >= MAX_TOTAL_PAYLOADS + totalPayloads >= MAX_TOTAL_PAYLOADS ) { return OfferResult.Overflow(removeLocked(token)?.flowIds.orEmpty()) } candidate.flowIds += flowId candidate.payloads += BufferedPayload(flowId, payload) + totalPayloads += 1 return OfferResult.Accepted } @@ -136,6 +158,7 @@ internal object PendingPlatoonAdmissionStore { val candidate = candidates[token] ?: return null if (candidate.claimed) return null candidate.claimed = true + notifyChanged() return Claim( summary = candidate.summary(), payloads = candidate.payloads.toList(), @@ -146,22 +169,45 @@ internal object PendingPlatoonAdmissionStore { @Synchronized fun releaseClaim(token: String) { - candidates[token]?.claimed = false + val candidate = candidates[token] ?: return + if (!candidate.claimed) return + candidate.claimed = false + notifyChanged() } @Synchronized fun complete(token: String): Set = removeLocked(token)?.flowIds.orEmpty() + /** + * Claims [token] and runs [admit] outside the lock. The candidate is completed when [admit] + * returns true and released for another decision otherwise, including when it throws, so a + * failed confirmation can never leave a candidate claimed and invisible forever. + * Returns the completed candidate's flow IDs, or null when nothing was completed. + */ + fun withClaim(token: String, admit: (Claim) -> Boolean): Set? { + val claim = claim(token) ?: return null + var admitted = false + try { + admitted = admit(claim) + } finally { + if (!admitted) releaseClaim(token) + } + return if (admitted) complete(token) else null + } + @Synchronized fun discard(token: String): Set = removeLocked(token)?.flowIds.orEmpty() @Synchronized fun contains(token: String): Boolean = token in candidates + @VisibleForTesting @Synchronized internal fun clearForTests() { candidates.clear() tokenByIdentity.clear() + totalPayloads = 0 + listeners.clear() } private fun Candidate.summary() = Summary( @@ -175,9 +221,20 @@ internal object PendingPlatoonAdmissionStore { private fun removeLocked(token: String): Candidate? { val removed = candidates.remove(token) ?: return null tokenByIdentity.remove(IdentityKey(removed.ownerPackage, removed.profile.platoonId), token) + totalPayloads -= removed.payloads.size + notifyChanged() return removed } + /** Snapshots are taken on the main thread so listeners never run under this lock. */ + private fun notifyChanged() { + if (listeners.isEmpty()) return + MainThread.post { + val current = summaries() + listeners.forEach { it(current) } + } + } + internal const val MAX_CANDIDATES = 4 internal const val MAX_PAYLOADS_PER_CANDIDATE = 64 internal const val MAX_TOTAL_PAYLOADS = 128 diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt new file mode 100644 index 0000000..2f69e16 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -0,0 +1,211 @@ +package dev.gf2log.app.capture + +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.PlatoonProfileData + +/** + * Buffers a previously unknown Platoon identity in [PendingPlatoonAdmissionStore] and later + * confirms, replays, or discards it. Runs on the capture worker both during capture and after it + * stopped, because candidates stay in memory until the user decides or the process dies. + */ +internal class PlatoonAdmissionCoordinator( + private val profileRegistry: PlatoonProfileRegistry, + private val clientServerRegions: ClientServerRegionPreferences, + private val flows: Flows, +) { + /** The flow state an admission decision acts on; owned by [CaptureFlowEngine]. */ + interface Flows { + fun isOpen(flowId: Long): Boolean + fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession + fun unbind(flowId: Long, session: PlatoonCaptureSession) + fun route(session: PlatoonCaptureSession, payload: ParsedPayload) + fun quarantine(flowId: Long) + fun profileChanged(storageId: String) + } + + private val tokenByFlow = HashMap() + + fun beginSession() = admissionGate.clear() + + fun endSession() { + tokenByFlow.forEach { (flowId, token) -> PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) } + tokenByFlow.clear() + } + + fun flowClosed(flowId: Long) { + tokenByFlow.remove(flowId)?.let { PendingPlatoonAdmissionStore.markFlowEnded(it, flowId) } + } + + /** + * Returns true when [flowId] already feeds a candidate. A changed identity discards that + * candidate and quarantines the flow instead of re-binding it. + */ + fun claimsIdentity(flowId: Long, ownerPackage: String, data: PlatoonProfileData): Boolean { + val token = tokenByFlow[flowId] ?: return false + val pending = PendingPlatoonAdmissionStore.summary(token) + if (pending?.ownerPackage != ownerPackage || pending.profile.platoonId != data.platoonId) { + discard(token) + flows.quarantine(flowId) + CaptureStatus.report(CaptureNotice.PENDING_IDENTITY_CHANGED) + } + return true + } + + /** Returns true when [payload] was consumed by the candidate [flowId] belongs to. */ + fun retain(flowId: Long, payload: ParsedPayload): Boolean { + val token = tokenByFlow[flowId] ?: return false + offer(token, flowId, payload) + return true + } + + fun begin( + flowId: Long, + ownerPackage: String, + data: PlatoonProfileData, + preIdentityPayloads: List, + ) { + val result = PendingPlatoonAdmissionStore.begin(ownerPackage, data, flowId) + val token = result.token + if (token == null) { + result.rejectedFlowIds.forEach(flows::quarantine) + CaptureStatus.report(CaptureNotice.PENDING_QUEUE_FULL) + return + } + tokenByFlow[flowId] = token + preIdentityPayloads.forEach { if (!offer(token, flowId, it)) return } + CaptureStatus.report(CaptureNotice.PENDING_DETECTED) + } + + fun confirm(token: String, region: GameServerRegion) { + var admitted: PlatoonProfile? = null + val completedFlows = PendingPlatoonAdmissionStore.withClaim(token) { claim -> + admitted = admit(claim, region) + admitted != null + } ?: return + completedFlows.forEach { tokenByFlow.remove(it, token) } + val profile = admitted ?: return + CaptureStatus.report( + CaptureNotice.PLATOON_CONFIRMED, + profile.platoonName.take(MAX_STATUS_NAME_CHARS), + profile.platoonId.toString(), + profile.client.displayName, + ) + } + + /** Persists the claimed identity and replays its payloads; null after reporting a failure. */ + private fun admit(claim: PendingPlatoonAdmissionStore.Claim, region: GameServerRegion): PlatoonProfile? { + val ownerPackage = claim.summary.ownerPackage + if (region !in clientServerRegions.allowed(ownerPackage)) { + CaptureStatus.report(CaptureNotice.ADMISSION_REGION_MISMATCH) + return null + } + val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) + val existing = profileRegistry.findByIdentity( + client, + region, + claim.summary.profile.platoonId.toLong(), + ) + if (!admissionGate.canAdmit(ownerPackage, existing != null)) { + CaptureStatus.report(CaptureNotice.ADMISSION_LIMIT) + return null + } + val previousRegion = clientServerRegions.stored(ownerPackage) + val profile = runCatching { + clientServerRegions.set(ownerPackage, region) + profileRegistry.upsertDetected(ownerPackage, region, claim.summary.profile).also { + check(profileRegistry.setActive(it.storageId)) { + "Unable to select the confirmed Platoon" + } + } + }.getOrElse { error -> + runCatching { + if (previousRegion == null) { + clientServerRegions.clear(ownerPackage) + } else { + clientServerRegions.set(ownerPackage, previousRegion) + } + }.onFailure(error::addSuppressed) + CaptureStatus.report(CaptureNotice.ADMISSION_PROFILE_FAILED) + return null + } + if (existing == null) admissionGate.markAdmitted(ownerPackage) + flows.profileChanged(profile.storageId) + if (!replay(claim, profile)) { + CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) + return null + } + return profile + } + + /** Binds live flows to [profile] and routes the buffered payloads in their captured order. */ + private fun replay(claim: PendingPlatoonAdmissionStore.Claim, profile: PlatoonProfile): Boolean { + val sessions = mutableMapOf() + val replayed = runCatching { + // Live flows stay bound; an ended flow needs a session only while its payloads replay. + claim.flowIds.filterNot(claim.endedFlowIds::contains).forEach { flowId -> + sessions[flowId] = flows.bind(flowId, profile) + } + claim.payloads.forEach { buffered -> + flows.route( + sessions.getOrPut(buffered.flowId) { flows.bind(buffered.flowId, profile) }, + buffered.payload, + ) + } + } + if (replayed.isFailure) { + sessions.forEach { (flowId, session) -> flows.unbind(flowId, session) } + return false + } + claim.endedFlowIds.forEach { flowId -> + sessions.remove(flowId)?.let { flows.unbind(flowId, it) } + } + return true + } + + fun discard(token: String) { + PendingPlatoonAdmissionStore.discard(token).forEach { flowId -> + tokenByFlow.remove(flowId, token) + if (flows.isOpen(flowId)) flows.quarantine(flowId) + } + CaptureStatus.report(CaptureNotice.PENDING_DISCARDED) + } + + /** Returns false when the flow can no longer contribute to the candidate. */ + private fun offer(token: String, flowId: Long, payload: ParsedPayload): Boolean = + when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, payload)) { + PendingPlatoonAdmissionStore.OfferResult.Accepted -> true + PendingPlatoonAdmissionStore.OfferResult.Claimed -> { + CaptureStatus.report(CaptureNotice.ADMISSION_IN_PROGRESS) + true + } + PendingPlatoonAdmissionStore.OfferResult.Missing -> { + tokenByFlow.remove(flowId, token) + flows.quarantine(flowId) + false + } + is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { + result.rejectedFlowIds.forEach { rejected -> + tokenByFlow.remove(rejected, token) + flows.quarantine(rejected) + } + CaptureStatus.report(CaptureNotice.PENDING_OVERFLOW) + false + } + } + + companion object { + const val MAX_STATUS_NAME_CHARS = 40 + + /** + * Process-wide like the pending candidates it limits: the service, and with it this + * coordinator, is recreated after capture stops, while candidates from that capture can + * still be confirmed. A per-instance gate would reset and admit every one of them. + */ + private val admissionGate = PlatoonProfilePolicy.AdmissionGate() + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt index df72d3c..a24b833 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt @@ -7,13 +7,12 @@ import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.protocol.model.ParsedPayload import java.time.Instant -/** Owns all mutable capture-to-management state for one identified TCP flow. */ +/** Routes one identified TCP flow's payloads into its verified Platoon scope. */ internal class PlatoonCaptureSession( context: Context, val profile: PlatoonProfile, private val onRosterCaptured: (String) -> Unit, - private val onStatus: (String) -> Unit, -) : AutoCloseable { +) { private val scope = PlatoonStorageScope(profile.storageId) private val repository = PlatoonRepository(context, scope) private val writer = GuildMembersCsvWriter( @@ -26,10 +25,7 @@ internal class PlatoonCaptureSession( onUpdates = { repository.ingestUpdates(it).acceptedObservations > 0 }, ) - fun dispatch(payload: ParsedPayload, flowEnded: Boolean = false): PlatoonPayloadDispatcher.Results = - dispatcher.dispatch(payload, flowEnded) - - override fun close() = writer.close() + fun dispatch(payload: ParsedPayload): PlatoonPayloadDispatcher.Results = dispatcher.dispatch(payload) private fun ingestCompletedRoster(batch: GuildMembersCsvWriter.CompletedBatch) { val directResult = runCatching { @@ -42,9 +38,11 @@ internal class PlatoonCaptureSession( if (directResult.isSuccess) { val result = directResult.getOrThrow() if (!result.duplicate) { - onStatus( - "Updated ${profile.platoonName}: +${result.joined + result.rejoined}, " + - "-${result.left}", + CaptureStatus.report( + CaptureNotice.ROSTER_UPDATED, + profile.platoonName, + (result.joined + result.rejoined).toString(), + result.left.toString(), ) } onRosterCaptured(profile.storageId) @@ -63,7 +61,7 @@ internal class PlatoonCaptureSession( ?: recovery.exceptionOrNull() ?: IllegalStateException("Roster ingestion and recovery both failed") } - onStatus("Recovered ${profile.platoonName} from the completed roster CSV") + CaptureStatus.report(CaptureNotice.ROSTER_RECOVERED, profile.platoonName) onRosterCaptured(profile.storageId) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt index 152737f..be06913 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt @@ -1,29 +1,24 @@ package dev.gf2log.app.capture +import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonActivityData import dev.gf2log.protocol.model.PlatoonUpdatesData +/** Routes each Platoon payload to its handler; one handler's failure never suppresses another. */ internal class PlatoonPayloadDispatcher( - private val onMembers: (ParsedPayload, Boolean) -> GuildMembersCsvWriter.SaveResult?, + private val onMembers: (ParsedPayload) -> GuildMembersCsvWriter.SaveResult?, private val onActivity: (PlatoonActivityData) -> Boolean, private val onUpdates: (PlatoonUpdatesData) -> Boolean, ) { - fun dispatch(payload: ParsedPayload, flowEnded: Boolean = false): Results { - val activity = (payload.data as? PlatoonActivityData)?.let { data -> - runCatching { onActivity(data) } - } - val updates = (payload.data as? PlatoonUpdatesData)?.let { data -> - runCatching { onUpdates(data) } - } - // Every decoded payload reaches the writer so a non-roster payload can - // close an unterminated roster continuation before later data arrives. - val members = runCatching { onMembers(payload, flowEnded) } - return Results(members, activity, updates) - } + fun dispatch(payload: ParsedPayload): Results = Results( + members = payload.takeIf { it.data is GuildMembersData }?.let { runCatching { onMembers(it) } }, + activity = (payload.data as? PlatoonActivityData)?.let { runCatching { onActivity(it) } }, + updates = (payload.data as? PlatoonUpdatesData)?.let { runCatching { onUpdates(it) } }, + ) data class Results( - val members: Result, + val members: Result?, val activity: Result?, val updates: Result?, ) diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt index 4d44ff3..9232ae9 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt @@ -12,17 +12,28 @@ internal object PlatoonProfilePolicy { profile.platoonName.length <= MAX_NAME_LENGTH && profile.platoonName.none(Char::isISOControl) + /** + * A flow bound to [boundStorageId] may only keep reporting that scope. Any other identity, + * including one that is not registered yet, is a change: admitting it while the flow stays + * bound would route the new Platoon's payloads into the old scope. + */ + fun changesBoundIdentity(boundStorageId: String?, observedStorageId: String?): Boolean = + boundStorageId != null && boundStorageId != observedStorageId + /** One user-started capture may persist at most one new profile per supported client. */ internal class AdmissionGate { private val admittedClients = mutableSetOf() + @Synchronized fun canAdmit(ownerPackage: String, alreadyRegistered: Boolean): Boolean = alreadyRegistered || ownerPackage !in admittedClients + @Synchronized fun markAdmitted(ownerPackage: String) { admittedClients += ownerPackage } + @Synchronized fun clear() = admittedClients.clear() } } diff --git a/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt b/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt index d3b6331..f0da03e 100644 --- a/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt +++ b/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt @@ -8,14 +8,10 @@ internal class ScopedCaptureChecklist(private val requiredTypes: Set) { private val capturedByScope = ConcurrentHashMap>() private val targetScope = AtomicReference(null) - fun mark(scopeId: String, payloadType: Int, chooseTarget: Boolean): Set { + fun mark(scopeId: String, payloadType: Int, chooseTarget: Boolean) { require(scopeId.isNotBlank()) if (chooseTarget) targetScope.compareAndSet(null, scopeId) - val captured = capturedByScope.computeIfAbsent(scopeId) { - ConcurrentHashMap.newKeySet() - } - captured += payloadType - return captured.toSet() + capturedByScope.computeIfAbsent(scopeId) { ConcurrentHashMap.newKeySet() } += payloadType } fun targetScopeId(): String? = targetScope.get() @@ -26,9 +22,4 @@ internal class ScopedCaptureChecklist(private val requiredTypes: Set) { .orEmpty() fun targetComplete(): Boolean = targetCaptured().containsAll(requiredTypes) - - fun clear() { - capturedByScope.clear() - targetScope.set(null) - } } diff --git a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt index 2fbf1e3..c6768d0 100644 --- a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt @@ -16,7 +16,8 @@ object DiscordWebhookPolicy { require(uri.userInfo == null && uri.fragment == null && uri.query == null) { "Discord webhook URL cannot contain credentials, fragments, or query parameters" } - require(uri.path.matches(WEBHOOK_PATH)) { "Invalid Discord incoming-webhook path" } + // The raw path is what is sent, so percent-encoded spellings cannot pass as canonical. + require(uri.rawPath.orEmpty().matches(WEBHOOK_PATH)) { "Invalid Discord incoming-webhook path" } return uri } diff --git a/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt b/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt index 4e3d21b..8b535c8 100644 --- a/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt +++ b/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt @@ -6,19 +6,16 @@ import dev.gf2log.protocol.ParsedPacketTableParser object OriginalCsvPayload { fun extract(historyContent: String): String { require(historyContent.length <= ParsedPacketTableParser.MAX_CONTENT_CHARS) - val normalized = historyContent.replace("\r\n", "\n").replace('\r', '\n') - val separator = normalized.indexOf("\n\n") - require(separator >= 0 && separator + 2 < normalized.length) { + val body = requireNotNull(ParsedPacketTableParser.csvBody(historyContent)) { "Parsed packet does not contain an original CSV body" } - requireNotNull(ParsedPacketTableParser.parse(normalized)) { + requireNotNull(ParsedPacketTableParser.parse(historyContent)) { "Parsed packet CSV failed bounded validation" } - return normalized.substring(separator + 2).also { - require(it.toByteArray(Charsets.UTF_8).size <= MAX_CSV_BYTES) { - "Original CSV exceeds the Discord send limit" - } + require(body.toByteArray(Charsets.UTF_8).size <= MAX_CSV_BYTES) { + "Original CSV exceeds the Discord send limit" } + return body } const val MAX_CSV_BYTES = 512 * 1024 diff --git a/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt b/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt new file mode 100644 index 0000000..e61fd9f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt @@ -0,0 +1,28 @@ +package dev.gf2log.app.history + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +internal object AtomicFiles { + /** Moves [source] to [destination], atomically when the filesystem supports it. */ + fun move(source: File, destination: File) { + try { + Files.move(source.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), destination.toPath()) + } + } + + /** Writes through a sibling temporary file so readers never observe partial content. */ + fun write(destination: File, content: ByteArray, temporaryPrefix: String) { + val temporary = File.createTempFile(temporaryPrefix, ".tmp", destination.parentFile) + try { + temporary.writeBytes(content) + move(temporary, destination) + } finally { + temporary.delete() + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt index 4531b4e..fc50267 100644 --- a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt @@ -1,99 +1,51 @@ package dev.gf2log.app.history +import dev.gf2log.protocol.LogTime import dev.gf2log.protocol.ParsedPayloadTextFormatter import dev.gf2log.protocol.model.ParsedPayload import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.time.Clock import java.time.Instant import java.time.ZoneId -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter import java.util.concurrent.atomic.AtomicLong +/** Recent parsed-packet history; each save rotates out the oldest entries beyond [MAX_ENTRIES]. */ class CaptureHistoryStore( - private val directory: File, + directory: File, private val clock: Clock = Clock.systemUTC(), - private val displayZone: ZoneId? = null, + displayZone: ZoneId? = null, ) { - @Synchronized - fun save(payload: ParsedPayload): Entry { - check(directory.isDirectory || directory.mkdirs()) { "Unable to create history directory" } + private val entries = HistoryDirectory(directory, displayZone) + + fun save(payload: ParsedPayload): Entry = synchronized(entries.lock) { + entries.ensureExists("history") val instant = Instant.now(clock) - val capturedAt = LOG_TIME_FORMAT.format(instant) - val id = "${instant.toEpochMilli()}_${payload.payloadType}_${sequence.incrementAndGet()}.txt" - val destination = File(directory, id) - val temporary = File.createTempFile("packet_", ".tmp", directory) - try { - val content = ParsedPayloadTextFormatter.format(payload, capturedAt) - .toByteArray(Charsets.UTF_8) - require(content.size <= MAX_ENTRY_BYTES) { - "Parsed-packet history entry exceeds the storage limit" - } - temporary.writeBytes(content) - try { - Files.move( - temporary.toPath(), - destination.toPath(), - StandardCopyOption.ATOMIC_MOVE, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(temporary.toPath(), destination.toPath()) - } - } finally { - temporary.delete() + val content = ParsedPayloadTextFormatter.format(payload, LogTime.format(instant)) + .toByteArray(Charsets.UTF_8) + require(content.size <= MAX_ENTRY_BYTES) { + "Parsed-packet history entry exceeds the storage limit" } + val id = "${instant.toEpochMilli()}_${payload.payloadType}_${sequence.incrementAndGet()}" + + ".${HistoryDirectory.ENTRY_EXTENSION}" + val destination = File(entries.directory, id) + AtomicFiles.write(destination, content, "packet_") destination.setLastModified(instant.toEpochMilli()) trimToLimit() - return destination.toEntry() + entries.toEntry(destination) } - @Synchronized - fun list(): List { - directory.mkdirs() - trimToLimit() - return historyFiles() - .sortedWith(compareByDescending { it.lastModified() }.thenByDescending { it.name }) - .map { it.toEntry() } - } + fun list(): List = entries.list() - @Synchronized - fun read(id: String): String? { - if (id.isBlank() || File(id).name != id) return null - val file = File(directory, id) - if (!file.isFile || file.parentFile?.canonicalFile != directory.canonicalFile) return null - if (file.length() > MAX_ENTRY_BYTES) return null - return file.readText(Charsets.UTF_8) - } + fun read(id: String): String? = entries.read(id) - @Synchronized - fun delete(ids: Collection): Int = ids - .asSequence() - .filter { it.isNotBlank() && File(it).name == it } - .map { File(directory, it) } - .filter { it.isFile && it.parentFile?.canonicalFile == directory.canonicalFile } - .count(File::delete) + fun delete(ids: Collection): Int = entries.delete(ids) private fun trimToLimit() { - val oldestFirst = historyFiles() + val oldestFirst = entries.files() .sortedWith(compareBy { it.lastModified() }.thenBy { it.name }) oldestFirst.take((oldestFirst.size - MAX_ENTRIES).coerceAtLeast(0)).forEach(File::delete) } - private fun historyFiles(): List = directory.listFiles() - .orEmpty() - .filter { it.isFile && it.extension == "txt" } - - private fun File.toEntry(): Entry = Entry( - id = name, - title = TITLE_TIME_FORMAT - .withZone(displayZone ?: ZoneId.systemDefault()) - .format(Instant.ofEpochMilli(lastModified())), - payloadType = name.split('_').getOrNull(1)?.toIntOrNull(), - ) - data class Entry( val id: String, val title: String, @@ -105,10 +57,5 @@ class CaptureHistoryStore( const val MAX_ENTRY_BYTES = 2 * 1024 * 1024 const val HISTORY_DIRECTORY = "capture-history" private val sequence = AtomicLong() - private val LOG_TIME_FORMAT = DateTimeFormatter - .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") - .withZone(ZoneOffset.UTC) - private val TITLE_TIME_FORMAT = DateTimeFormatter - .ofPattern("yy/MM/dd HH:mm:ss") } } diff --git a/app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt b/app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt new file mode 100644 index 0000000..1769335 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt @@ -0,0 +1,66 @@ +package dev.gf2log.app.history + +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap + +/** + * Path-safe access to one directory of parsed-packet text entries. + * + * Activities and the capture service construct their own stores, so every instance for the same + * canonical directory shares one process-wide lock. Reads never delete; rotation belongs to writers. + */ +internal class HistoryDirectory( + val directory: File, + private val displayZone: ZoneId? = null, +) { + val lock: Any = locks.computeIfAbsent(directory.canonicalPath) { Any() } + + fun ensureExists(description: String) { + check(directory.isDirectory || directory.mkdirs()) { "Unable to create $description directory" } + } + + fun list(): List = synchronized(lock) { + files() + .sortedWith(compareByDescending { it.lastModified() }.thenByDescending { it.name }) + .map(::toEntry) + } + + fun read(id: String): String? = synchronized(lock) { + val file = resolveExisting(id) ?: return null + if (file.length() > CaptureHistoryStore.MAX_ENTRY_BYTES) return null + file.readText(Charsets.UTF_8) + } + + fun delete(ids: Collection): Int = synchronized(lock) { + ids.mapNotNull(::resolveExisting).count(File::delete) + } + + /** Returns the entry file named by [id] without allowing traversal; it may not exist yet. */ + fun resolve(id: String): File? = + File(directory, id).takeIf { id.isNotBlank() && File(id).name == id } + + fun files(): List = directory.listFiles() + .orEmpty() + .filter { it.isFile && it.extension == ENTRY_EXTENSION } + + private fun resolveExisting(id: String): File? = resolve(id)?.takeIf { + it.isFile && it.parentFile?.canonicalFile == directory.canonicalFile + } + + fun toEntry(file: File) = CaptureHistoryStore.Entry( + id = file.name, + title = TITLE_TIME_FORMAT + .withZone(displayZone ?: ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(file.lastModified())), + payloadType = file.name.split('_').getOrNull(1)?.toIntOrNull(), + ) + + companion object { + const val ENTRY_EXTENSION = "txt" + private val locks = ConcurrentHashMap() + private val TITLE_TIME_FORMAT = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") + } +} diff --git a/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt b/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt index defba1c..6083305 100644 --- a/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt @@ -1,84 +1,59 @@ package dev.gf2log.app.history import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption +/** Explicitly saved history; capped at [MAX_ENTRIES] and never rotated. */ class SavedHistoryStore( - private val directory: File, + directory: File, ) { - private val entries = CaptureHistoryStore(directory) - - @Synchronized - fun saveFrom(source: CaptureHistoryStore, ids: Collection): SaveResult { - check(directory.isDirectory || directory.mkdirs()) { "Unable to create saved-history directory" } - - var saved = 0 - var alreadySaved = 0 - var missing = 0 - var limitReached = false - var storedCount = savedFiles().size - - ids.distinct().forEach { id -> - if (id.isBlank() || File(id).name != id) { - missing += 1 - return@forEach - } - - val destination = File(directory, id) - if (destination.isFile) { - alreadySaved += 1 - return@forEach - } - - val content = source.read(id) - if (content == null) { - missing += 1 - return@forEach - } - if (storedCount >= MAX_ENTRIES) { - limitReached = true - return@forEach + private val entries = HistoryDirectory(directory) + + fun saveFrom(source: CaptureHistoryStore, ids: Collection): SaveResult = + synchronized(entries.lock) { + entries.ensureExists("saved-history") + + var saved = 0 + var alreadySaved = 0 + var missing = 0 + var limitReached = false + var storedCount = entries.files().size + + ids.distinct().forEach { id -> + val destination = entries.resolve(id) + if (destination == null) { + missing += 1 + return@forEach + } + if (destination.isFile) { + alreadySaved += 1 + return@forEach + } + + val content = source.read(id) + if (content == null) { + missing += 1 + return@forEach + } + if (storedCount >= MAX_ENTRIES) { + limitReached = true + return@forEach + } + + AtomicFiles.write(destination, content.toByteArray(Charsets.UTF_8), "saved_packet_") + id.substringBefore('_').toLongOrNull()?.let(destination::setLastModified) + saved += 1 + storedCount += 1 } - writeAtomically(destination, content) - id.substringBefore('_').toLongOrNull()?.let(destination::setLastModified) - saved += 1 - storedCount += 1 + SaveResult(saved, alreadySaved, missing, limitReached) } - return SaveResult(saved, alreadySaved, missing, limitReached) - } - fun list(): List = entries.list() fun read(id: String): String? = entries.read(id) fun delete(ids: Collection): Int = entries.delete(ids) - private fun writeAtomically(destination: File, content: String) { - val temporary = File.createTempFile("saved_packet_", ".tmp", directory) - try { - temporary.writeText(content, Charsets.UTF_8) - try { - Files.move( - temporary.toPath(), - destination.toPath(), - StandardCopyOption.ATOMIC_MOVE, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(temporary.toPath(), destination.toPath()) - } - } finally { - temporary.delete() - } - } - - private fun savedFiles(): List = directory.listFiles() - .orEmpty() - .filter { it.isFile && it.extension == "txt" } - data class SaveResult( val saved: Int, val alreadySaved: Int, diff --git a/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt b/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt index 9a1bdab..3ad67db 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt @@ -41,16 +41,12 @@ object ActivityInference { ) } - // Function Name: candidatesForScore - // Description: - // - Enumerates every valid Gunsmoke activity state for a captured score. - // - Supports a missing merit boundary without inventing one specific attendance or patrol state. - // - Uses the captured single-attempt high score as an optional aggregate-score ceiling. - // Parameters: - // - scoreDelta: Gunsmoke score accumulated in the current game day. - // - maximumAttemptScore: Largest possible score for one observed attempt, or null if unavailable. - // Returns: - // - Returns every compatible merit, attempt, attendance, patrol, and score-merit combination. + /** + * Enumerates every valid Gunsmoke activity state for a captured score. Supports a missing merit + * boundary without inventing one specific attendance or patrol state. Uses the captured + * single-attempt high score as an optional aggregate-score ceiling. Returns every compatible + * merit, attempt, attendance, patrol, and score-merit combination. + */ fun candidatesForScore( scoreDelta: Long, maximumAttemptScore: Long? = null, @@ -92,17 +88,11 @@ object ActivityInference { }.distinct() } - // Function Name: isCompatibleScore - // Description: - // - Verifies the exact aggregate score-to-merit relationship for a known attempt count. - // - Accounts for per-attempt integer rounding and an optional captured high-score ceiling. - // Parameters: - // - scoreDelta: Aggregate score earned by the attempts. - // - scoreMerit: Sum of the per-attempt score merit after integer division by ten. - // - attempts: Number of attempts represented by the aggregate. - // - maximumAttemptScore: Captured single-attempt upper bound, or null when unavailable. - // Returns: - // - True only when at least one per-attempt score distribution satisfies every constraint. + /** + * Verifies the exact aggregate score-to-merit relationship for a known attempt count. Accounts + * for per-attempt integer rounding and an optional captured high-score ceiling. Returns true + * only when at least one per-attempt score distribution satisfies every constraint. + */ internal fun isCompatibleScore( scoreDelta: Long, scoreMerit: Long, @@ -171,13 +161,13 @@ object ActivityInference { ): Boolean = score / attempts > maximumAttemptScore || (score / attempts == maximumAttemptScore && score % attempts != 0L) - private fun saturatedMultiply(value: Long, factor: Int): Long = when { + fun saturatedMultiply(value: Long, factor: Int): Long = when { factor == 0 -> 0L value > Long.MAX_VALUE / factor -> Long.MAX_VALUE else -> value * factor } - private fun saturatedAdd(left: Long, right: Long): Long = + fun saturatedAdd(left: Long, right: Long): Long = if (left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right fun counterDelta(previous: Long, current: Long): Long = @@ -221,6 +211,11 @@ object ActivityInference { val dailyPatrol: Boolean, ) + /** Daily non-Gunsmoke merit: login attendance, and Daily Patrol on top of it. */ + const val LOGIN_MERIT = 50L + const val DAILY_PATROL_MERIT = 40L + const val LOGIN_AND_PATROL_MERIT = LOGIN_MERIT + DAILY_PATROL_MERIT + const val MAX_DAILY_ATTEMPTS = 3 const val MAX_WEEKLY_ATTEMPTS = MAX_DAILY_ATTEMPTS * 7 const val MERIT_PER_ATTEMPT = 30L diff --git a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt new file mode 100644 index 0000000..d3dfed0 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt @@ -0,0 +1,413 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.ExactBoundaryCorrelation.applyExactMembershipBoundary +import dev.gf2log.app.management.PlatoonDatabase.Companion.DAILY_PATROL_REWARD_ACTION_ID +import dev.gf2log.app.management.PlatoonDatabase.Companion.MAX_STORED_ACTIVITY_OBSERVATIONS +import java.time.Instant + +/** Stores 21935 activity facts, resolves names to UIDs, and correlates them with inferred boundaries. */ +internal object ActivityIngestion { + fun ingest( + db: SQLiteDatabase, + observations: List, + capturedAt: Instant, + ): ActivityIngestResult { + val acceptedObservations = PlatoonObservationPolicy.activity(observations) + if (acceptedObservations.isEmpty()) return ActivityIngestResult(0, 0, 0) + return db.inTransaction { + var inserted = 0 + var resolved = 0 + acceptedObservations.forEach { observation -> + val resolvedUid = resolveUidForName( + db, + observation.memberName, + observation.occurredAt, + ) + val result = db.insertWithOnConflict( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", observation.occurredAt.toEpochMilli()) + put("action_id", observation.actionId) + put("kind", observation.kind) + put("member_name", observation.memberName) + put("captured_at", capturedAt.toEpochMilli()) + putNullableLong("resolved_uid", resolvedUid) + put( + "resolution", + if (resolvedUid == null) { + ActivityResolution.UNRESOLVED.name + } else { + ActivityResolution.UNIQUE_ROSTER_NAME.name + }, + ) + }, + SQLiteDatabase.CONFLICT_IGNORE, + ) + if (result != -1L) { + inserted += 1 + if (resolvedUid != null) resolved += 1 + } + } + trimPlatoonActivity(db) + resolveUnresolvedActivityUids(db) + reconcileInferredMembershipBoundaries(db) + ActivityIngestResult(acceptedObservations.size, inserted, resolved) + } + } + + private fun resolveUidForName( + db: SQLiteDatabase, + memberName: String, + occurredAt: Instant, + ): Long? { + val windowStart = occurredAt.minusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() + val windowEnd = occurredAt.plusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() + val historical = db.rawQuery( + """ + SELECT DISTINCT sm.uid + FROM snapshot_members sm + JOIN snapshots s ON s.id = sm.snapshot_id + WHERE sm.name = ? COLLATE BINARY + AND s.captured_at >= ? + AND s.captured_at <= ? + LIMIT 2 + """.trimIndent(), + arrayOf(memberName, windowStart.toString(), windowEnd.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(0)) + } + } + if (historical.size == 1) return historical.single() + if (historical.size > 1) return null + + val current = db.rawQuery( + "SELECT uid FROM members WHERE current_name = ? COLLATE BINARY LIMIT 2", + arrayOf(memberName), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(0)) + } + } + return current.singleOrNull() + } + + fun resolveUnresolvedActivityUids(db: SQLiteDatabase) { + PlatoonDatabaseSchema.createPlatoonMaintenanceStateTable(db) + val cursorId = db.rawQuery( + "SELECT value FROM platoon_maintenance_state WHERE key = ?", + arrayOf(ACTIVITY_RESOLUTION_CURSOR_KEY), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getLong(0) else 0L + } + val unresolved = readUnresolvedActivityBatch( + db = db, + selection = "resolved_uid IS NULL AND id > ?", + selectionArgs = arrayOf(cursorId.toString()), + limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS, + ).toMutableList() + if (unresolved.size < MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS) { + unresolved += readUnresolvedActivityBatch( + db = db, + selection = "resolved_uid IS NULL AND id <= ?", + selectionArgs = arrayOf(cursorId.toString()), + limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS - unresolved.size, + ) + } + unresolved.forEach { activity -> + val uid = resolveUidForName(db, activity.memberName, activity.occurredAt) + ?: return@forEach + val updated = db.updateWithOnConflict( + "platoon_activity", + ContentValues().apply { + put("resolved_uid", uid) + put("resolution", ActivityResolution.UNIQUE_ROSTER_NAME.name) + }, + "id = ? AND resolved_uid IS NULL", + arrayOf(activity.id.toString()), + SQLiteDatabase.CONFLICT_IGNORE, + ) + if (updated == 0) { + db.delete( + "platoon_activity", + """ + id = ? AND EXISTS ( + SELECT 1 + FROM platoon_activity existing + WHERE existing.id != ? + AND existing.occurred_at = ? + AND existing.action_id = ? + AND existing.kind = ? + AND existing.resolved_uid = ? + ) + """.trimIndent(), + arrayOf( + activity.id.toString(), + activity.id.toString(), + activity.occurredAt.toEpochMilli().toString(), + activity.actionId.toString(), + activity.kind.toString(), + uid.toString(), + ), + ) + } + } + unresolved.lastOrNull()?.let { activity -> + db.insertWithOnConflict( + "platoon_maintenance_state", + null, + ContentValues().apply { + put("key", ACTIVITY_RESOLUTION_CURSOR_KEY) + put("value", activity.id) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + } + + private fun readUnresolvedActivityBatch( + db: SQLiteDatabase, + selection: String, + selectionArgs: Array, + limit: Int, + ): List { + if (limit <= 0) return emptyList() + return db.query( + "platoon_activity", + arrayOf("id", "occurred_at", "member_name", "action_id", "kind"), + selection, + selectionArgs, + null, + null, + "id", + limit.toString(), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + UnresolvedActivity( + id = cursor.getLong(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + memberName = cursor.getString(2), + actionId = cursor.getLong(3), + kind = cursor.getLong(4), + ), + ) + } + } + } + } + + /** + * Caps network-derived activity evidence to the newest bounded history. Runs on open and after + * each ingest so an older oversized database self-heals. + */ + fun trimPlatoonActivity(db: SQLiteDatabase) { + db.execSQL( + """ + DELETE FROM platoon_activity + WHERE id IN ( + SELECT id + FROM platoon_activity + ORDER BY captured_at DESC, id DESC + LIMIT -1 OFFSET $MAX_STORED_ACTIVITY_OBSERVATIONS + ) + """.trimIndent(), + ) + } + + fun correlateMembershipBoundary( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + boundary: MembershipBoundary, + observedAt: Instant, + from: Instant?, + nameIsUnique: Boolean, + ) { + if (!nameIsUnique || from == null) return + val candidates = readMembershipActivityCandidates( + db = db, + memberName = memberName, + from = from, + until = observedAt, + ) + val occurredAt = candidates.map(ActivityCandidate::occurredAt).distinct().singleOrNull() + ?: return + applyExactMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + memberName = memberName, + type = type, + boundary = boundary, + occurredAt = occurredAt, + observedAt = observedAt, + activityIds = candidates.map(ActivityCandidate::id), + ) + } + + private fun readMembershipActivityCandidates( + db: SQLiteDatabase, + memberName: String, + from: Instant, + until: Instant, + ): List = db.rawQuery( + """ + SELECT id, occurred_at + FROM platoon_activity + WHERE member_name = ? COLLATE BINARY + AND occurred_at > ? + AND occurred_at <= ? + AND action_id NOT IN (?, ?) + AND member_event_id IS NULL + ORDER BY occurred_at, id + """.trimIndent(), + arrayOf( + memberName, + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + DAILY_PATROL_REWARD_ACTION_ID.toString(), + DAILY_PATROL_RELATED_ACTION_ID.toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(ActivityCandidate(cursor.getLong(0), Instant.ofEpochMilli(cursor.getLong(1)))) + } + } + } + + /** + * Upgrades an inferred roster boundary to an exact one when a single linked activity fact + * falls within the correlation window. Candidate boundaries are loaded once per ingest; an + * applied boundary becomes exact and therefore leaves the candidate set. + */ + private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { + val boundariesByUid = readInferredBoundaries(db) + if (boundariesByUid.isEmpty()) return + val activities = db.rawQuery( + """ + SELECT id, occurred_at, member_name, resolved_uid + FROM platoon_activity + WHERE member_event_id IS NULL + AND resolved_uid IS NOT NULL + AND action_id NOT IN (?, ?) + AND resolved_uid IN ( + SELECT uid FROM membership_periods + WHERE joined_precision = ? OR left_precision = ? + ) + ORDER BY occurred_at, id + """.trimIndent(), + arrayOf( + DAILY_PATROL_REWARD_ACTION_ID.toString(), + DAILY_PATROL_RELATED_ACTION_ID.toString(), + EvidencePrecision.INFERRED.name, + EvidencePrecision.INFERRED.name, + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + ResolvedActivityCandidate( + id = cursor.getLong(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + memberName = cursor.getString(2), + uid = cursor.getLong(3), + ), + ) + } + } + } + activities.forEach { activity -> + val candidates = boundariesByUid[activity.uid] ?: return@forEach + val boundary = candidates.singleOrNull { candidate -> + kotlin.math.abs( + candidate.inferredAt.toEpochMilli() - activity.occurredAt.toEpochMilli(), + ) <= MEMBERSHIP_CORRELATION_WINDOW_MILLIS + } ?: return@forEach + val eventType = when (boundary.boundary) { + MembershipBoundary.WITHDRAW -> MemberEventType.LEFT + MembershipBoundary.JOIN -> + MembershipRows.joinEventType(db, boundary.uid, boundary.membershipPeriodId) + } + applyExactMembershipBoundary( + db = db, + membershipPeriodId = boundary.membershipPeriodId, + uid = boundary.uid, + memberName = activity.memberName, + type = eventType, + boundary = boundary.boundary, + occurredAt = activity.occurredAt, + observedAt = boundary.inferredAt, + activityIds = listOf(activity.id), + ) + candidates.remove(boundary) + } + } + + private fun readInferredBoundaries(db: SQLiteDatabase): Map> = + db.rawQuery( + """ + SELECT id, uid, joined_at, left_at, joined_precision, left_precision + FROM membership_periods + WHERE (joined_at IS NOT NULL AND joined_precision = ?) + OR (left_at IS NOT NULL AND left_precision = ?) + """.trimIndent(), + arrayOf(EvidencePrecision.INFERRED.name, EvidencePrecision.INFERRED.name), + ).use { cursor -> + val boundaries = mutableMapOf>() + while (cursor.moveToNext()) { + val membershipPeriodId = cursor.getLong(0) + val uid = cursor.getLong(1) + val forMember = boundaries.getOrPut(uid, ::mutableListOf) + val joinedAt = cursor.getNullableInstant(2) + if (joinedAt != null && cursor.getString(4) == EvidencePrecision.INFERRED.name) { + forMember += InferredBoundary(membershipPeriodId, uid, MembershipBoundary.JOIN, joinedAt) + } + val leftAt = cursor.getNullableInstant(3) + if (leftAt != null && cursor.getNullableString(5) == EvidencePrecision.INFERRED.name) { + forMember += InferredBoundary(membershipPeriodId, uid, MembershipBoundary.WITHDRAW, leftAt) + } + } + boundaries + } + + private data class ActivityCandidate( + val id: Long, + val occurredAt: Instant, + ) + + private data class ResolvedActivityCandidate( + val id: Long, + val occurredAt: Instant, + val memberName: String, + val uid: Long, + ) + + private data class UnresolvedActivity( + val id: Long, + val occurredAt: Instant, + val memberName: String, + val actionId: Long, + val kind: Long, + ) + + private data class InferredBoundary( + val membershipPeriodId: Long, + val uid: Long, + val boundary: MembershipBoundary, + val inferredAt: Instant, + ) + + private const val MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS = 250 + private const val ACTIVITY_RESOLUTION_CURSOR_KEY = "activity_resolution_cursor" + private const val DAILY_PATROL_RELATED_ACTION_ID = 801005L + private const val NAME_RESOLUTION_WINDOW_MILLIS = 30L * 24L * 60L * 60L * 1000L + private const val MEMBERSHIP_CORRELATION_WINDOW_MILLIS = 12L * 60L * 60L * 1000L +} diff --git a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt index 120567c..2c5ca41 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.management import dev.gf2log.app.StrictProperties +import dev.gf2log.app.settings.GameServerRegion import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.ByteArrayInputStream @@ -9,7 +10,7 @@ import java.io.File import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream -import java.security.MessageDigest +import java.time.Instant import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -38,7 +39,7 @@ internal object BackupArchive { zip.putNextEntry(ZipEntry(MANIFEST_ENTRY)) Properties().apply { setProperty(KEY_FORMAT_VERSION, formatVersion.toString()) - setProperty(KEY_DATABASE_SHA256, database.sha256()) + setProperty(KEY_DATABASE_SHA256, database.sha256Hex()) if (profile != null) { setProperty(KEY_APPLICATION_ID, APPLICATION_ID) setProperty( @@ -46,11 +47,11 @@ internal object BackupArchive { if (settings == null) PLATOON_SCOPE else COMPLETE_SCOPE, ) putProfile(profile) - if (settings != null) setProperty(KEY_SETTINGS_SHA256, settings.sha256()) + if (settings != null) setProperty(KEY_SETTINGS_SHA256, settings.sha256().toHex()) } else if (settings != null) { setProperty(KEY_APPLICATION_ID, APPLICATION_ID) setProperty(KEY_BACKUP_SCOPE, COMPLETE_SCOPE) - setProperty(KEY_SETTINGS_SHA256, settings.sha256()) + setProperty(KEY_SETTINGS_SHA256, settings.sha256().toHex()) } }.store(zip, "mobileGF2logger backup") zip.closeEntry() @@ -143,7 +144,7 @@ internal object BackupArchive { } require( metadata.getProperty(KEY_DATABASE_SHA256) - .equals(stagedDatabase.sha256(), ignoreCase = true), + .equals(stagedDatabase.sha256Hex(), ignoreCase = true), ) { "Backup database checksum does not match" } val profile = if ( @@ -176,7 +177,7 @@ internal object BackupArchive { val settings = requireNotNull(settingsBytes) { "Backup settings are missing" } require( metadata.getProperty(KEY_SETTINGS_SHA256) - .equals(settings.sha256(), ignoreCase = true), + .equals(settings.sha256().toHex(), ignoreCase = true), ) { "Backup settings checksum does not match" } } else { require(settingsBytes == null) { "Legacy backup contains unexpected settings" } @@ -197,7 +198,7 @@ internal object BackupArchive { internal data class BackupPlatoonProfile( val storageId: String, val client: PlatoonClient, - val serverRegion: dev.gf2log.app.settings.GameServerRegion, + val serverRegion: GameServerRegion, val platoonId: Long, val platoonName: String, val bannerFrameId: Long, @@ -212,7 +213,7 @@ internal object BackupArchive { platoonName = platoonName, bannerFrameId = bannerFrameId, bannerMarkId = bannerMarkId, - lastSeenAt = java.time.Instant.now(), + lastSeenAt = Instant.now(), legacy = legacy, ) } @@ -230,7 +231,7 @@ internal object BackupArchive { private fun Properties.profile(formatVersion: Int): BackupPlatoonProfile { val client = PlatoonClient.valueOf(required(KEY_PROFILE_CLIENT)) - val region = dev.gf2log.app.settings.GameServerRegion.fromStored(required(KEY_PROFILE_REGION)) + val region = GameServerRegion.fromStored(required(KEY_PROFILE_REGION)) require(region.storedValue == required(KEY_PROFILE_REGION)) { "Backup server region is invalid" } val id = requireNotNull(required(KEY_PROFILE_PLATOON_ID).toLongOrNull()) { "Backup Platoon ID is invalid" @@ -285,25 +286,6 @@ internal object BackupArchive { } } - private fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val count = input.read(buffer) - if (count < 0) break - digest.update(buffer, 0, count) - } - } - return digest.digest().hex() - } - - private fun ByteArray.sha256(): String = MessageDigest.getInstance("SHA-256") - .digest(this) - .hex() - - private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } - /** Lets ZIP cleanup release its deflater without closing the caller-owned destination. */ private class NonClosingOutputStream(output: OutputStream) : FilterOutputStream(output) { override fun close() = flush() diff --git a/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt b/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt index 9cb9bcd..c7fae1e 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt @@ -19,11 +19,24 @@ internal object BackupFormatPolicy { } fun requireComplete(formatVersion: Int, hasSettings: Boolean) { - require( - formatVersion in setOf(COMPLETE_VERSION, LEGACY_SCOPED_VERSION, SCOPED_VERSION) && - hasSettings, - ) { + require(supportsComplete(formatVersion, hasSettings)) { "Backup does not contain complete app settings" } } + + fun supportsComplete(formatVersion: Int, hasSettings: Boolean): Boolean = + formatVersion in setOf(COMPLETE_VERSION, LEGACY_SCOPED_VERSION, SCOPED_VERSION) && hasSettings + + /** + * The restore an archive receives. A null [requestComplete] auto-detects: archives carrying + * restorable app settings are restored completely, anything else as Platoon data only. + */ + fun restoredKind( + formatVersion: Int, + hasSettings: Boolean, + requestComplete: Boolean?, + ): RestoredBackupKind = when (requestComplete ?: supportsComplete(formatVersion, hasSettings)) { + true -> RestoredBackupKind.COMPLETE + false -> RestoredBackupKind.PLATOON + } } diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index 6d94684..c621008 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -1,7 +1,7 @@ package dev.gf2log.app.management import android.content.Context -import dev.gf2log.app.settings.AppSettingsStore +import dev.gf2log.app.settings.ScopedAppSettingsStore import java.io.File import java.io.FileOutputStream @@ -35,26 +35,28 @@ class CsvImportCheckpointManager internal constructor( private val root = File(profileRoot, CHECKPOINT_DIRECTORY) private val staging = File(profileRoot, STAGING_DIRECTORY) private val previous = File(profileRoot, PREVIOUS_DIRECTORY) + private val discarded = File(profileRoot, DISCARDED_DIRECTORY) + + /** Claims are per scope: an operation in one Platoon never blocks another Platoon's import. */ + private val scopeState = synchronized(SCOPE_STATES) { + SCOPE_STATES.getOrPut(storageScope.storageId, ::ScopeState) + } init { - synchronized(STATE_LOCK) { - if (!operationInProgress) recoverInterruptedState() + synchronized(scopeState) { + if (!scopeState.operationInProgress) recoverInterruptedState() } } - fun canUndo(): Boolean = synchronized(STATE_LOCK) { - !operationInProgress && hasBaseCheckpoint() && digest(root).isFile + fun canUndo(): Boolean = synchronized(scopeState) { + !scopeState.operationInProgress && hasBaseCheckpoint() && digest(root).isFile } private fun hasBaseCheckpoint(): Boolean = archive(root).isFile && manifest(root).isFile - // Function Name: create - // Description: - // - Exports the current database before any selected CSV is retained. - // - Atomically publishes the archive with the deterministic file identities the import may add. - // Parameters: - // - plannedFileNames: Validated retained CSV identities for non-duplicate selected files. - // Returns: - // - Unit after a durable provisional checkpoint is published beside its predecessor. + /** + * Exports the current database before any selected CSV is retained. Atomically publishes the + * archive with the deterministic file identities the import may add. + */ fun create(plannedFileNames: Set) { claimOperation() var published = false @@ -68,11 +70,7 @@ class CsvImportCheckpointManager internal constructor( deleteDirectory(staging) check(staging.mkdirs()) { "Unable to stage the CSV import checkpoint" } FileOutputStream(archive(staging)).use { output -> - PlatoonBackupManager( - appContext, - AppSettingsStore(appContext), - storageScope = storageScope, - ).export(output) + backupManager().export(output) output.fd.sync() } FileOutputStream(manifest(staging)).use { output -> @@ -101,6 +99,14 @@ class CsvImportCheckpointManager internal constructor( /** Seals a successful import so undo is refused after any later database mutation. */ fun seal() { + try { + sealClaimed() + } finally { + releaseOperation() + } + } + + private fun sealClaimed() { check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } val target = digest(root) check(!target.exists()) { "CSV import checkpoint is already sealed" } @@ -108,12 +114,7 @@ class CsvImportCheckpointManager internal constructor( try { FileOutputStream(temporary).use { output -> output.write( - PlatoonBackupManager( - appContext, - AppSettingsStore(appContext), - storageScope = storageScope, - ).currentDatabaseSha256() - .toByteArray(Charsets.US_ASCII), + backupManager().currentDatabaseSha256().toByteArray(Charsets.US_ASCII), ) output.fd.sync() } @@ -121,18 +122,33 @@ class CsvImportCheckpointManager internal constructor( } finally { temporary.delete() } - deleteDirectory(previous) - releaseOperation() + discard(previous) } - // Function Name: restore - // Description: - // - Quarantines only files planned by the reverted import, then restores the database. - // - Restores quarantined evidence if database replacement fails. - // Parameters: - // - None. - // Returns: - // - Unit after the checkpoint is consumed. + /** + * Runs one complete import: checkpoint, [apply], then seal. Any failure, including an + * [Error], rolls the import back, and the scope's claim is always released. + */ + fun runImport(plannedFileNames: Set, apply: () -> T): T { + create(plannedFileNames) + // The claim taken by create is held across apply, seal, and any rollback, and released + // exactly once, so no other operation on this scope can interleave with a rollback. + try { + val result = apply() + sealClaimed() + return result + } catch (failure: Throwable) { + runCatching(::rollbackClaimed).exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } finally { + releaseOperation() + } + } + + /** + * Quarantines only files planned by the reverted import, then restores the database. Restores + * quarantined evidence if database replacement fails. + */ fun restore() { claimOperation() try { @@ -141,13 +157,7 @@ class CsvImportCheckpointManager internal constructor( } val expectedDigest = digest(root).readText(Charsets.US_ASCII) require(expectedDigest.matches(SHA256)) { "Invalid CSV checkpoint digest" } - check( - PlatoonBackupManager( - appContext, - AppSettingsStore(appContext), - storageScope = storageScope, - ).currentDatabaseSha256() == expectedDigest, - ) { + check(backupManager().currentDatabaseSha256() == expectedDigest) { "Platoon data changed after the CSV import; undo would overwrite newer changes" } restoreUnchecked() @@ -158,13 +168,17 @@ class CsvImportCheckpointManager internal constructor( internal fun rollbackFailedImport() { try { - check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } - restoreUnchecked() + rollbackClaimed() } finally { releaseOperation() } } + private fun rollbackClaimed() { + check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } + restoreUnchecked() + } + private fun restoreUnchecked() { val names = manifest(root).readLines(Charsets.UTF_8) .filter(String::isNotBlank) @@ -188,17 +202,12 @@ class CsvImportCheckpointManager internal constructor( check(source.renameTo(target)) { "Unable to quarantine an imported CSV" } } } - val backupManager = PlatoonBackupManager( - context = appContext, - settingsStore = AppSettingsStore(appContext), - storageScope = storageScope, - restoreObserver = { checkpoint -> - if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { - markRestoreDatabaseInstalled() - } - restoreObserver(checkpoint) - }, - ) + val backupManager = backupManager { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + markRestoreDatabaseInstalled() + } + restoreObserver(checkpoint) + } archive(root).inputStream().use(backupManager::restoreCheckpoint) } catch (error: Exception) { val marker = restoreMarker(root) @@ -210,20 +219,17 @@ class CsvImportCheckpointManager internal constructor( restoreQuarantinedFiles(names, retained, quarantine, error) throw error } - deleteDirectory(root) + discard(root) restorePreviousCheckpoint() } - // Function Name: recoverInterruptedState - // Description: - // - Restores an interrupted checkpoint publication, import rollback, or undo operation. - // - Keeps a sealed latest checkpoint and discards its obsolete predecessor. - // Parameters: - // - None. - // Returns: - // - Unit after checkpoint state is usable or the interrupted mutation is rolled back. + /** + * Restores an interrupted checkpoint publication, import rollback, or undo operation. Keeps a + * sealed latest checkpoint and discards its obsolete predecessor. + */ private fun recoverInterruptedState() { deleteDirectory(staging) + deleteDirectory(discarded) if (!root.exists()) { restorePreviousCheckpoint() return @@ -233,7 +239,7 @@ class CsvImportCheckpointManager internal constructor( if (!digest(root).isFile || restoreMarker(root).isFile || quarantine.exists()) { restoreUnchecked() } else if (previous.exists()) { - deleteDirectory(previous) + discard(previous) } } @@ -285,22 +291,53 @@ class CsvImportCheckpointManager internal constructor( } private fun claimOperation() { - synchronized(STATE_LOCK) { - check(!operationInProgress) { "Another CSV checkpoint operation is in progress" } - operationInProgress = true + synchronized(scopeState) { + check(!scopeState.operationInProgress) { + "Another CSV checkpoint operation is in progress" + } + scopeState.operationInProgress = true } } private fun releaseOperation() { - synchronized(STATE_LOCK) { - operationInProgress = false + synchronized(scopeState) { + scopeState.operationInProgress = false } } + private class ScopeState { + var operationInProgress = false + } + + /** + * Always binds restore recovery to this scope's settings. The global store would roll an + * interrupted scoped restore back into the legacy keys instead of the Platoon's own settings. + */ + private fun backupManager( + observer: (PlatoonBackupManager.RestoreCheckpoint) -> Unit = {}, + ) = PlatoonBackupManager( + context = appContext, + settingsStore = ScopedAppSettingsStore(appContext, storageScope.storageId), + restoreObserver = observer, + storageScope = storageScope, + ) + private fun requireSafeImportName(name: String) { require(name.matches(SAFE_IMPORT_NAME)) { "Invalid CSV checkpoint identity" } } + /** + * Retires a checkpoint directory with one atomic rename before deleting it. A recursive delete + * interrupted midway would otherwise leave a live-looking checkpoint without its archive. + * A leftover retired copy is inert and removed again by the next recovery. + */ + private fun discard(directory: File) { + if (!directory.exists()) return + deleteDirectory(discarded) + check(directory.renameTo(discarded)) { "Unable to retire CSV import checkpoint state" } + discarded.deleteRecursively() + } + private fun deleteDirectory(directory: File) { if (directory.exists()) check(directory.deleteRecursively()) { "Unable to remove CSV import checkpoint state" @@ -313,14 +350,12 @@ class CsvImportCheckpointManager internal constructor( private fun restoreMarker(directory: File) = File(directory, RESTORE_MARKER) private companion object { - val STATE_LOCK = Any() - - @Volatile - var operationInProgress = false + val SCOPE_STATES = mutableMapOf() const val CHECKPOINT_DIRECTORY = "csv-import-checkpoint" const val STAGING_DIRECTORY = "csv-import-checkpoint.staging" const val PREVIOUS_DIRECTORY = "csv-import-checkpoint.previous" + const val DISCARDED_DIRECTORY = "csv-import-checkpoint.discarded" const val QUARANTINE_DIRECTORY = "retained-quarantine" const val ARCHIVE_FILE = "platoon.gf2backup" const val MANIFEST_FILE = "planned-files.txt" diff --git a/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt b/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt index 2cf994d..318a620 100644 --- a/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt +++ b/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import android.database.Cursor import android.database.sqlite.SQLiteDatabase internal data class DatabaseSchemaContract( @@ -121,7 +122,7 @@ internal data class DatabaseSchemaContract( } private fun readColumns( - cursor: android.database.Cursor, + cursor: Cursor, hiddenColumn: Int?, ): Map = cursor.run { buildMap { @@ -228,7 +229,7 @@ internal data class DatabaseSchemaContract( private fun normalizeSql(value: String): String = value.trim().replace(WHITESPACE, " ").uppercase() - private fun android.database.Cursor.getStringOrNull(column: Int): String? = + private fun Cursor.getStringOrNull(column: Int): String? = if (isNull(column)) null else getString(column) private val AUTO_INCREMENT = Regex("\\bAUTOINCREMENT\\b", RegexOption.IGNORE_CASE) diff --git a/app/src/main/java/dev/gf2log/app/management/Digests.kt b/app/src/main/java/dev/gf2log/app/management/Digests.kt new file mode 100644 index 0000000..724033f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/Digests.kt @@ -0,0 +1,31 @@ +package dev.gf2log.app.management + +import java.io.File +import java.security.MessageDigest + +private val HEX_DIGITS = "0123456789abcdef".toCharArray() + +/** Lowercase hexadecimal, two characters per byte. */ +internal fun ByteArray.toHex(): String = buildString(size * 2) { + this@toHex.forEach { byte -> + val value = byte.toInt() and 0xff + append(HEX_DIGITS[value ushr 4]) + append(HEX_DIGITS[value and 0x0f]) + } +} + +internal fun ByteArray.sha256(): ByteArray = MessageDigest.getInstance("SHA-256").digest(this) + +/** Streams the file through SHA-256 and returns the lowercase hexadecimal digest. */ +internal fun File.sha256Hex(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().toHex() +} diff --git a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt new file mode 100644 index 0000000..c97e864 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt @@ -0,0 +1,671 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.MembershipRows.ensureRosterActiveMembershipPeriod +import dev.gf2log.app.management.MembershipRows.insertEvent +import dev.gf2log.app.management.MembershipRows.insertMembershipPeriod +import dev.gf2log.app.management.MembershipRows.joinEventType +import dev.gf2log.app.management.MembershipRows.memberExists +import dev.gf2log.app.management.PlatoonDatabase.Companion.DAILY_PATROL_REWARD_ACTION_ID +import java.time.Instant + +/** Applies UID-safe 21960 Updates boundaries and folds weaker roster or manual evidence into them. */ +internal object ExactBoundaryCorrelation { + fun ingest( + db: SQLiteDatabase, + observations: List, + capturedAt: Instant, + ): UpdatesIngestResult { + val acceptedObservations = PlatoonObservationPolicy.updates(observations) + if (acceptedObservations.isEmpty()) return UpdatesIngestResult(0, 0, 0) + return db.inTransaction { + var membershipEvents = 0 + var patrolFacts = 0 + acceptedObservations.forEach { observation -> + observation.members.forEach { + ensureUpdateMember(db, it, observation.occurredAt) + } + val affectedMembers = PlatoonUpdateSemantics.affectedMembers( + kind = observation.kind, + members = observation.members, + ) + val effect = PlatoonUpdateSemantics.effect(observation.kind) + val exactBoundary = EXACT_BOUNDARIES[effect] + if (exactBoundary != null) { + val (type, boundary) = exactBoundary + affectedMembers.forEach { member -> + if (applyExactUpdateBoundary( + db = db, + member = member, + type = type, + boundary = boundary, + occurredAt = observation.occurredAt, + observedAt = capturedAt, + ) + ) { + membershipEvents += 1 + } + synchronizeCurrentMembershipFromUpdate(db, member.uid) + } + } else if (effect == PlatoonUpdateEffect.DAILY_PATROL) { + affectedMembers.forEach { member -> + val inserted = db.insertWithOnConflict( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", observation.occurredAt.toEpochMilli()) + put("action_id", DAILY_PATROL_REWARD_ACTION_ID) + put("kind", observation.kind) + put("member_name", member.name) + put("captured_at", capturedAt.toEpochMilli()) + put("resolved_uid", member.uid) + put("resolution", ActivityResolution.EXACT_UPDATE.name) + }, + SQLiteDatabase.CONFLICT_IGNORE, + ) + if (inserted != -1L) patrolFacts += 1 + } + } + } + ActivityIngestion.trimPlatoonActivity(db) + UpdatesIngestResult(acceptedObservations.size, membershipEvents, patrolFacts) + } + } + + private fun synchronizeCurrentMembershipFromUpdate( + db: SQLiteDatabase, + uid: Long, + ) { + MembershipRows.synchronizeActiveState(db, listOf(uid)) + val latestSnapshotAt = SnapshotIngestion.latestSnapshotCapturedAt(db) + val snapshotFilter = if (latestSnapshotAt == null) "" else "AND occurred_at > ?" + val arguments = mutableListOf( + uid.toString(), + EvidenceSource.GAME_UPDATES.name, + EvidencePrecision.EXACT.name, + MemberEventType.JOINED.name, + MemberEventType.REJOINED.name, + MemberEventType.LEFT.name, + MemberEventType.REMOVED.name, + ).apply { + latestSnapshotAt?.let { add(it.toEpochMilli().toString()) } + } + val latestBoundary = db.rawQuery( + """ + SELECT note, occurred_at + FROM member_events + WHERE uid = ? + AND source = ? + AND precision = ? + AND event_type IN (?, ?, ?, ?) + AND occurred_at IS NOT NULL + $snapshotFilter + ORDER BY occurred_at DESC, + CASE event_type + WHEN '${MemberEventType.REMOVED.name}' THEN 0 + WHEN '${MemberEventType.LEFT.name}' THEN 1 + WHEN '${MemberEventType.REJOINED.name}' THEN 2 + ELSE 3 + END, + id DESC + LIMIT 1 + """.trimIndent(), + arguments.toTypedArray(), + ).use { cursor -> + if (!cursor.moveToFirst()) return + LatestMembershipBoundary( + name = cursor.getString(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + ) + } + db.update( + "members", + ContentValues().apply { + put("current_name", latestBoundary.name) + put("last_seen_at", latestBoundary.occurredAt.toEpochMilli()) + }, + "uid = ?", + arrayOf(uid.toString()), + ) + } + + private data class LatestMembershipBoundary( + val name: String, + val occurredAt: Instant, + ) + + private fun ensureUpdateMember( + db: SQLiteDatabase, + member: PlatoonUpdateMemberObservation, + observedAt: Instant, + ) { + if (memberExists(db, member.uid)) return + MembershipRows.insertMember( + db = db, + uid = member.uid, + name = member.name, + level = 0, + firstSeenAt = observedAt, + lastSeenAt = observedAt, + ) + } + + fun findRosterConfirmedExactMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + from: Instant?, + observedAt: Instant, + ): Long? { + val boundaryColumn = boundary.atColumn + val sourceColumn = boundary.sourceColumn + val precisionColumn = boundary.precisionColumn + val compatibility = if (boundary == MembershipBoundary.JOIN) { + "(left_at IS NULL OR left_at >= ?)" + } else { + "(joined_at IS NULL OR joined_at <= ?)" + } + val fromClause = if (from == null) "" else "AND $boundaryColumn > ?" + val arguments = buildList { + add(uid.toString()) + add(EvidenceSource.GAME_UPDATES.name) + add(EvidencePrecision.EXACT.name) + add(observedAt.toEpochMilli().toString()) + if (from != null) add(from.toEpochMilli().toString()) + add(observedAt.toEpochMilli().toString()) + }.toTypedArray() + val candidates = db.rawQuery( + """ + SELECT id, $boundaryColumn + FROM membership_periods + WHERE uid = ? + AND $sourceColumn = ? + AND $precisionColumn = ? + AND $boundaryColumn <= ? + $fromClause + AND $compatibility + ORDER BY $boundaryColumn DESC, id DESC + """.trimIndent(), + arguments, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(cursor.getLong(0) to Instant.ofEpochMilli(cursor.getLong(1))) + } + } + } + return candidates.firstOrNull { (_, boundaryAt) -> + !hasLaterOppositeBoundaryThrough( + db = db, + uid = uid, + boundary = boundary, + boundaryAt = boundaryAt, + observedAt = observedAt, + ) + }?.first + } + + private fun applyExactUpdateBoundary( + db: SQLiteDatabase, + member: PlatoonUpdateMemberObservation, + type: MemberEventType, + boundary: MembershipBoundary, + occurredAt: Instant, + observedAt: Instant, + ): Boolean { + val boundaryTypes = boundary.eventTypes + val exactExists = db.rawQuery( + """ + SELECT 1 + FROM member_events + WHERE uid = ? + AND source = ? + AND occurred_at = ? + AND event_type IN (?, ?) + LIMIT 1 + """.trimIndent(), + arrayOf( + member.uid.toString(), + EvidenceSource.GAME_UPDATES.name, + occurredAt.toEpochMilli().toString(), + boundaryTypes[0].name, + boundaryTypes[1].name, + ), + ).use(Cursor::moveToFirst) + if (exactExists) return false + + val candidates = db.rawQuery( + """ + SELECT id, membership_period_id, COALESCE(occurred_at, observed_at), + ABS(COALESCE(occurred_at, observed_at) - ?) AS distance + FROM member_events + WHERE uid = ? + AND event_type IN (?, ?) + AND source != ? + AND ABS(COALESCE(occurred_at, observed_at) - ?) <= ? + ORDER BY distance, + CASE source WHEN '${EvidenceSource.MANUAL.name}' THEN 0 ELSE 1 END, + id DESC + """.trimIndent(), + arrayOf( + occurredAt.toEpochMilli().toString(), + member.uid.toString(), + boundaryTypes[0].name, + boundaryTypes[1].name, + EvidenceSource.GAME_UPDATES.name, + occurredAt.toEpochMilli().toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + BoundaryEventCandidate( + id = cursor.getLong(0), + membershipPeriodId = cursor.getNullableLong(1), + boundaryAt = Instant.ofEpochMilli(cursor.getLong(2)), + ), + ) + } + } + } + val correlatableCandidates = candidates.filter { candidate -> + !hasOppositeBoundaryBetween( + db = db, + uid = member.uid, + boundary = boundary, + first = candidate.boundaryAt, + second = occurredAt, + ) + } + val membershipPeriodId = correlatableCandidates + .firstNotNullOfOrNull(BoundaryEventCandidate::membershipPeriodId) + ?: findOrCreateUpdateMembershipPeriod(db, member.uid, boundary, occurredAt) + + correlatableCandidates.forEach { candidate -> + db.delete("member_events", "id = ?", arrayOf(candidate.id.toString())) + } + correlatableCandidates.mapNotNull(BoundaryEventCandidate::membershipPeriodId) + .filter { it != membershipPeriodId } + .distinct() + .forEach { candidateMembershipPeriodId -> + mergeShadowInferredMembershipPeriod( + db = db, + selectedMembershipPeriodId = membershipPeriodId, + shadowMembershipPeriodId = candidateMembershipPeriodId, + boundary = boundary, + occurredAt = occurredAt, + ) + } + + applyExactMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = if (boundary == MembershipBoundary.JOIN) { + joinEventType(db, member.uid, membershipPeriodId) + } else { + type + }, + boundary = boundary, + occurredAt = occurredAt, + observedAt = observedAt, + activityIds = emptyList(), + ) + if (boundary == MembershipBoundary.WITHDRAW && + MembershipChronology.shouldRestoreRosterActiveMembershipPeriod( + memberIsActive = isActiveMember(db, member.uid), + withdrawalPredatesRoster = hasRosterPresenceAfter(db, member.uid, occurredAt), + ) + ) { + /* + * The historical boundary and a later authoritative roster are + * both true. Close the compatible historical membership period above, then + * preserve exactly one unknown current period for the later roster. + * A subsequent exact rejoin in the replay will refine this period. + */ + ensureRosterActiveMembershipPeriod(db, member.uid, EvidenceSource.SNAPSHOT) + } + return true + } + + private fun hasOppositeBoundaryBetween( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + first: Instant, + second: Instant, + ): Boolean { + if (first == second) return false + val oppositeTypes = boundary.opposite.eventTypes + val start = minOf(first, second).toEpochMilli() + val end = maxOf(first, second).toEpochMilli() + return db.rawQuery( + """ + SELECT 1 + FROM member_events + WHERE uid = ? + AND event_type IN (?, ?) + AND COALESCE(occurred_at, observed_at) > ? + AND COALESCE(occurred_at, observed_at) < ? + LIMIT 1 + """.trimIndent(), + arrayOf( + uid.toString(), + oppositeTypes[0].name, + oppositeTypes[1].name, + start.toString(), + end.toString(), + ), + ).use(Cursor::moveToFirst) + } + + private fun hasLaterOppositeBoundaryThrough( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + boundaryAt: Instant, + observedAt: Instant, + ): Boolean { + if (!observedAt.isAfter(boundaryAt)) return false + val oppositeTypes = boundary.opposite.eventTypes + val oppositeBoundaryAt = db.rawQuery( + """ + SELECT MIN(COALESCE(occurred_at, observed_at)) + FROM member_events + WHERE uid = ? + AND event_type IN (?, ?) + AND COALESCE(occurred_at, observed_at) > ? + AND COALESCE(occurred_at, observed_at) <= ? + """.trimIndent(), + arrayOf( + uid.toString(), + oppositeTypes[0].name, + oppositeTypes[1].name, + boundaryAt.toEpochMilli().toString(), + observedAt.toEpochMilli().toString(), + ), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getNullableInstant(0) else null + } + return MembershipChronology.hasSupersedingOppositeBoundary( + boundaryAt = boundaryAt, + observedAt = observedAt, + oppositeBoundaryTimes = listOfNotNull(oppositeBoundaryAt), + ) + } + + private fun findOrCreateUpdateMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + occurredAt: Instant, + ): Long { + val occurredAtMillis = occurredAt.toEpochMilli() + val nearby = when (boundary) { + MembershipBoundary.JOIN -> db.rawQuery( + """ + SELECT id + FROM membership_periods + WHERE uid = ? + AND (left_at IS NULL OR left_at >= ?) + AND ( + joined_at IS NULL + OR ( + joined_precision != ? + AND ABS(joined_at - ?) <= ? + ) + ) + ORDER BY + CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, + CASE WHEN joined_at IS NULL THEN 0 ELSE 1 END, + ABS(COALESCE(joined_at, ?) - ?), + id DESC + LIMIT 1 + """.trimIndent(), + arrayOf( + uid.toString(), + occurredAtMillis.toString(), + EvidencePrecision.EXACT.name, + occurredAtMillis.toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + occurredAtMillis.toString(), + occurredAtMillis.toString(), + ), + ) + MembershipBoundary.WITHDRAW -> db.rawQuery( + """ + SELECT id + FROM membership_periods + WHERE uid = ? + AND (joined_at IS NULL OR joined_at <= ?) + AND ( + left_at IS NULL + OR ( + left_precision != ? + AND ABS(left_at - ?) <= ? + ) + ) + ORDER BY + CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, + CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, + joined_at DESC, + ABS(COALESCE(left_at, ?) - ?), + id DESC + LIMIT 1 + """.trimIndent(), + arrayOf( + uid.toString(), + occurredAtMillis.toString(), + EvidencePrecision.EXACT.name, + occurredAtMillis.toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + occurredAtMillis.toString(), + occurredAtMillis.toString(), + ), + ) + }.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + if (nearby != null) return nearby + return insertMembershipPeriod( + db = db, + uid = uid, + joinedAt = if (boundary == MembershipBoundary.JOIN) occurredAt else null, + precision = if (boundary == MembershipBoundary.JOIN) { + EvidencePrecision.EXACT + } else { + EvidencePrecision.UNKNOWN + }, + source = EvidenceSource.GAME_UPDATES, + ) + } + + private fun isActiveMember(db: SQLiteDatabase, uid: Long): Boolean = + db.rawQuery( + "SELECT is_active FROM members WHERE uid = ? LIMIT 1", + arrayOf(uid.toString()), + ).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) != 0 } + + private fun hasRosterPresenceAfter( + db: SQLiteDatabase, + uid: Long, + occurredAt: Instant, + ): Boolean { + val latestRosterPresenceAt = db.rawQuery( + """ + SELECT MAX(snapshot.captured_at) + FROM snapshots snapshot + JOIN snapshot_members member ON member.snapshot_id = snapshot.id + WHERE member.uid = ? + """.trimIndent(), + arrayOf(uid.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getNullableInstant(0) else null + } + return MembershipChronology.withdrawalPredatesRosterPresence( + withdrewAt = occurredAt, + latestRosterPresenceAt = latestRosterPresenceAt, + ) + } + + /** + * Folds a weaker inferred period that recorded the same boundary into the selected period. + * Its opposite boundary moves over only when the selected period has none of its own. + */ + private fun mergeShadowInferredMembershipPeriod( + db: SQLiteDatabase, + selectedMembershipPeriodId: Long, + shadowMembershipPeriodId: Long, + boundary: MembershipBoundary, + occurredAt: Instant, + ) { + val opposite = boundary.opposite.prefix + val shadowOpposite = db.rawQuery( + """ + SELECT ${opposite}_at, ${opposite}_date, ${opposite}_time_known, + ${opposite}_precision, ${opposite}_source + FROM membership_periods + WHERE id = ? + AND ${boundary.precisionColumn} IN (?, ?) + AND ( + ${boundary.atColumn} IS NULL + OR ABS(${boundary.atColumn} - ?) <= ? + ) + LIMIT 1 + """.trimIndent(), + arrayOf( + shadowMembershipPeriodId.toString(), + EvidencePrecision.INFERRED.name, + EvidencePrecision.UNKNOWN.name, + occurredAt.toEpochMilli().toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + ), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + ShadowBoundary( + at = cursor.getNullableLong(0), + date = cursor.getNullableLong(1), + timeKnown = cursor.getNullableInt(2), + precision = cursor.getNullableString(3), + source = cursor.getNullableString(4), + ) + } + } ?: return + + if (shadowOpposite.at != null) { + val selectedHasOpposite = db.rawQuery( + "SELECT ${opposite}_at FROM membership_periods WHERE id = ?", + arrayOf(selectedMembershipPeriodId.toString()), + ).use { cursor -> cursor.moveToFirst() && !cursor.isNull(0) } + if (selectedHasOpposite) return + + db.update( + "membership_periods", + ContentValues().apply { + put("${opposite}_at", shadowOpposite.at) + putNullableLong("${opposite}_date", shadowOpposite.date) + putNullableInt("${opposite}_time_known", shadowOpposite.timeKnown) + put("${opposite}_precision", shadowOpposite.precision) + put("${opposite}_source", shadowOpposite.source) + }, + "id = ?", + arrayOf(selectedMembershipPeriodId.toString()), + ) + val oppositeTypes = boundary.opposite.eventTypes + db.update( + "member_events", + ContentValues().apply { put("membership_period_id", selectedMembershipPeriodId) }, + "membership_period_id = ? AND event_type IN (?, ?)", + arrayOf( + shadowMembershipPeriodId.toString(), + oppositeTypes[0].name, + oppositeTypes[1].name, + ), + ) + } + db.delete("member_events", "membership_period_id = ?", arrayOf(shadowMembershipPeriodId.toString())) + db.delete("membership_periods", "id = ?", arrayOf(shadowMembershipPeriodId.toString())) + } + + fun applyExactMembershipBoundary( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + boundary: MembershipBoundary, + occurredAt: Instant, + observedAt: Instant, + activityIds: List, + ) { + val values = ContentValues().apply { + put(boundary.atColumn, occurredAt.toEpochMilli()) + putNull("${boundary.prefix}_date") + put("${boundary.prefix}_time_known", 1) + put(boundary.precisionColumn, EvidencePrecision.EXACT.name) + put(boundary.sourceColumn, EvidenceSource.GAME_UPDATES.name) + } + db.update("membership_periods", values, "id = ?", arrayOf(membershipPeriodId.toString())) + val inferredTypes = boundary.eventTypes.map(MemberEventType::name) + db.delete( + "member_events", + "membership_period_id = ? AND source IN (?, ?) AND event_type IN (?, ?)", + arrayOf( + membershipPeriodId.toString(), + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + inferredTypes[0], + inferredTypes[1], + ), + ) + val eventId = insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = occurredAt, + observedAt = observedAt, + precision = EvidencePrecision.EXACT, + source = EvidenceSource.GAME_UPDATES, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + activityIds.forEach { activityId -> + db.update( + "platoon_activity", + ContentValues().apply { + put("resolved_uid", uid) + put("resolution", ActivityResolution.MEMBERSHIP_CORRELATION.name) + put("member_event_id", eventId) + }, + "id = ? AND member_event_id IS NULL", + arrayOf(activityId.toString()), + ) + } + } + + private data class BoundaryEventCandidate( + val id: Long, + val membershipPeriodId: Long?, + val boundaryAt: Instant, + ) + + private data class ShadowBoundary( + val at: Long?, + val date: Long?, + val timeKnown: Int?, + val precision: String?, + val source: String?, + ) + + private val EXACT_BOUNDARIES = mapOf( + PlatoonUpdateEffect.JOIN to (MemberEventType.JOINED to MembershipBoundary.JOIN), + PlatoonUpdateEffect.WITHDRAW to (MemberEventType.LEFT to MembershipBoundary.WITHDRAW), + PlatoonUpdateEffect.REMOVED to (MemberEventType.REMOVED to MembershipBoundary.WITHDRAW), + ) + + private const val EXACT_UPDATE_CORRELATION_WINDOW_MILLIS = 48L * 60L * 60L * 1000L +} diff --git a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt index aefd267..f9771dd 100644 --- a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt @@ -1,5 +1,7 @@ package dev.gf2log.app.management +import dev.gf2log.app.management.ActivityInference.saturatedAdd +import dev.gf2log.app.management.ActivityInference.saturatedMultiply import java.time.Duration import java.time.Instant import java.time.LocalDate @@ -15,35 +17,11 @@ import java.time.ZoneId * usable evidence. Only values shared by every compatible path are published. */ internal object GunsmokeWeekSolver { - fun solve( - uid: Long, - days: List, - zoneId: ZoneId, - snapshots: List, - cells: List, - dailyPatrolFacts: List, - ): List = resolve( - uid, - days, - zoneId, - snapshots, - cells, - dailyPatrolFacts, - ).cells - - // Function Name: resolve - // Description: - // - Solves daily Gunsmoke states and preserves whole-week totals shared by every valid path. - // - Keeps daily exactness independent from aggregate exactness when only the day placement varies. - // Parameters: - // - uid: Member whose counter history is being reconciled. - // - days: Seven game days in report order. - // - zoneId: Game-day timezone used for 05:00 and final-event boundaries. - // - snapshots: Available structured roster counter observations. - // - cells: Conservative daily projections built from direct boundaries. - // - dailyPatrolFacts: UID-safe timestamped Daily Patrol evidence. - // Returns: - // - Returns resolved daily cells and an optional final-week aggregate consensus. + /** + * Solves daily Gunsmoke states and preserves whole-week totals shared by every valid path. + * Keeps daily exactness independent from aggregate exactness when only the day placement + * varies. Returns resolved daily cells and an optional final-week aggregate consensus. + */ fun resolve( uid: Long, days: List, @@ -129,21 +107,12 @@ internal object GunsmokeWeekSolver { ) } - // Function Name: resolveFinalEventTotals - // Description: - // - Computes conservative whole-week activity totals when detailed path expansion reaches its budget. - // - Uses the Monday reset to establish Sunday and the final event counters to solve six-day aggregates. - // - Never assigns an aggregate possibility to a specific day. - // Parameters: - // - days: Seven report days from Sunday through Saturday. - // - zoneId: Game-day timezone for timestamp evidence. - // - anchor: Optional capture immediately before the Sunday boundary. - // - periodStart: Sunday report boundary instant. - // - checkpoints: One capture for every report day. - // - cells: Current conservative daily projections. - // - factsByDay: UID-safe Daily Patrol evidence grouped by game day. - // Returns: - // - Minimum or exact aggregate values compatible with all end-event arithmetic. + /** + * Computes conservative whole-week activity totals when detailed path expansion reaches its + * budget. Uses the Monday reset to establish Sunday and the final event counters to solve + * six-day aggregates. Never assigns an aggregate possibility to a specific day. Returns minimum + * or exact aggregate values compatible with all end-event arithmetic. + */ private fun resolveFinalEventTotals( days: List, zoneId: ZoneId, @@ -604,20 +573,12 @@ internal object GunsmokeWeekSolver { return minimum.toInt().takeIf { minimum in 1L..ActivityInference.MAX_WEEKLY_ATTEMPTS.toLong() } } - // Function Name: initialCapturedStates - // Description: - // - Establishes every valid state at the first capture in a contiguous run. - // - Uses a direct boundary when available, otherwise derives Sunday from the reset score counter. - // - Preserves all attendance, patrol, and score-rounding possibilities until later counters prune them. - // Parameters: - // - runStart: First observed day index in the contiguous run. - // - anchor: Latest snapshot before the report period, if one exists. - // - checkpoint: First member observation in the run. - // - zoneId: Game-day timezone used to derive the preceding counter boundary. - // - periodStart: Sunday 05:00 report boundary. - // - cell: Conservatively derived first-day cell. - // Returns: - // - Returns every compatible captured activity state. + /** + * Establishes every valid state at the first capture in a contiguous run. Uses a direct + * boundary when available, otherwise derives Sunday from the reset score counter. Preserves all + * attendance, patrol, and score-rounding possibilities until later counters prune them. Returns + * every compatible captured activity state. + */ private fun initialCapturedStates( runStart: Int, anchor: Checkpoint?, @@ -665,17 +626,11 @@ internal object GunsmokeWeekSolver { }.distinct() } - // Function Name: isClosedOpeningAnchor - // Description: - // - Accepts a near-reset capture or a preceding-week counter that has already reached its cap. - // - A capped Monday-through-Saturday counter cannot gain more merit before the Gunsmoke Sunday. - // Parameters: - // - capturedAt: Latest counter observation before the Gunsmoke report boundary. - // - weeklyMerit: Weekly counter observed at capturedAt. - // - zoneId: Game-day timezone used for the calendar reset boundary. - // - periodStart: Sunday 05:00 Gunsmoke report boundary. - // Returns: - // - True when no unobserved pre-boundary merit can contaminate the opening interval. + /** + * Accepts a near-reset capture or a preceding-week counter that has already reached its cap. A + * capped Monday-through-Saturday counter cannot gain more merit before the Gunsmoke Sunday. + * Returns true when no unobserved pre-boundary merit can contaminate the opening interval. + */ internal fun isClosedOpeningAnchor( capturedAt: Instant, weeklyMerit: Long, @@ -734,19 +689,12 @@ internal object GunsmokeWeekSolver { ) } - // Function Name: statesExtending - // Description: - // - Enumerates previous-day final states through their complementary current-day states. - // - Avoids scanning every score up to the raw counter delta when only 28 remainders can exist. - // - Refuses pathological counter ranges so malformed data degrades to conservative cells. - // Parameters: - // - prefix: State already captured during the previous game day. - // - maximumMerit: Largest previous-day final merit allowed by the counter transition. - // - meritDelta: Total-merit counter change between the two captures. - // - scoreDelta: Total-score counter change between the two captures. - // - maximumAttemptScore: Latest captured high score used as a safe per-attempt ceiling. - // Returns: - // - Valid extensions, or null when the deterministic search budget would be exceeded. + /** + * Enumerates previous-day final states through their complementary current-day states. Avoids + * scanning every score up to the raw counter delta when only 28 remainders can exist. Refuses + * pathological counter ranges so malformed data degrades to conservative cells. Returns valid + * extensions, or null when the deterministic search budget would be exceeded. + */ private fun statesExtending( prefix: ActivityState, minimumMerit: Long, @@ -1000,15 +948,6 @@ internal object GunsmokeWeekSolver { Long.MAX_VALUE } - private fun saturatedMultiply(value: Long, factor: Int): Long = when { - factor == 0 -> 0L - value > Long.MAX_VALUE / factor -> Long.MAX_VALUE - else -> value * factor - } - - private fun saturatedAdd(left: Long, right: Long): Long = - if (left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right - private fun matchesFinalEvidence( state: ActivityState, cell: WeeklyReportBuilder.DayCell, @@ -1127,8 +1066,6 @@ internal object GunsmokeWeekSolver { } } - private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = members.firstOrNull { it.uid == uid } - private fun SnapshotMember.hasValidActivityCounters(): Boolean = weeklyMerit >= 0L && totalMerit >= 0L && highScore >= 0L && totalScore >= 0L @@ -1201,13 +1138,13 @@ internal object GunsmokeWeekSolver { val maximumPatrolDays: Int, ) { val meritCertainty: MetricCertainty - get() = certainty(minimumMerit, maximumMerit) + get() = rangeCertainty(minimumMerit, maximumMerit) val attemptsCertainty: MetricCertainty - get() = certainty(minimumAttempts, maximumAttempts) + get() = rangeCertainty(minimumAttempts, maximumAttempts) val loginDaysCertainty: MetricCertainty - get() = certainty(minimumLoginDays, maximumLoginDays) + get() = rangeCertainty(minimumLoginDays, maximumLoginDays) val patrolDaysCertainty: MetricCertainty - get() = certainty(minimumPatrolDays, maximumPatrolDays) + get() = rangeCertainty(minimumPatrolDays, maximumPatrolDays) operator fun plus(state: ActivityState): ActivityTotalBounds = ActivityTotalBounds( minimumMerit = minimumMerit + state.merit, @@ -1243,12 +1180,6 @@ internal object GunsmokeWeekSolver { maximumPatrolDays = 0, ) - private fun certainty(minimum: Number, maximum: Number): MetricCertainty = - if (minimum.toLong() == maximum.toLong()) { - MetricCertainty.EXACT - } else { - MetricCertainty.LOWER_BOUND - } } } @@ -1382,8 +1313,8 @@ internal object GunsmokeWeekSolver { private const val MAX_PARTIAL_SOLUTIONS = 2_000 private const val MAX_SEARCH_OPERATIONS = 100_000 private const val MAX_AGGREGATE_SEARCH_OPERATIONS = 100_000 - private const val LOGIN_MERIT = 50L - private const val PATROL_MERIT = 40L + private const val LOGIN_MERIT = ActivityInference.LOGIN_MERIT + private const val PATROL_MERIT = ActivityInference.DAILY_PATROL_MERIT private const val MAX_PRE_GUNSMOKE_WEEKLY_MERIT = 6L * (LOGIN_MERIT + PATROL_MERIT) private const val DAILY_BASELINE_AND_ATTEMPT_MERIT = LOGIN_MERIT + PATROL_MERIT + diff --git a/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt b/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt new file mode 100644 index 0000000..c2eed02 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt @@ -0,0 +1,30 @@ +package dev.gf2log.app.management + +import dev.gf2log.protocol.GuildMembersCsv + +/** + * Bounds user-entered text at the persistence boundary. Each value is trimmed exactly as it is + * stored and then rejected, never silently truncated, when it exceeds its limit, so a caller + * that bypasses the input fields cannot store unbounded text and a failed save keeps the user's + * draft intact. Weekly history may still shorten stored text to its own byte budgets. + */ +internal object ManualTextPolicy { + /** The roster member-name limit, which the nickname field also enforces. */ + const val MAX_MEMBER_NAME_CHARS = GuildMembersCsv.MAX_MEMBER_NAME_CHARS + + /** One limit for every manual note, matching the weekly-note input field. */ + const val MAX_NOTE_CHARS = 1_000 + + /** The stored form of a custom member name. */ + fun memberName(value: String): String = value.trim().also { + require(it.isNotEmpty()) { "A member name cannot be blank" } + require(it.length <= MAX_MEMBER_NAME_CHARS) { + "A member name cannot exceed $MAX_MEMBER_NAME_CHARS characters" + } + } + + /** The stored form of a private member, membership-period, or weekly note. */ + fun note(value: String): String = value.trim().also { + require(it.length <= MAX_NOTE_CHARS) { "A note cannot exceed $MAX_NOTE_CHARS characters" } + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt index 9e9314e..80be714 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt @@ -2,7 +2,7 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate -import java.time.ZoneOffset +import java.time.ZoneId /** One persisted membership interval used by the deterministic consistency audit. */ internal data class MembershipInterval( @@ -17,7 +17,18 @@ internal data class MembershipInterval( /** Enforces chronology rules shared by manual edits and post-mutation verification. */ internal object MembershipConsistencyPolicy { - fun violation(periods: List): String? { + /** + * Manual boundaries store the calendar date they were entered for in the game zone, while + * snapshot and exact boundaries store only an instant. Each missing date is derived in + * [zoneId], so both kinds are compared on the same calendar. + */ + fun violation(intervals: List, zoneId: ZoneId): String? { + val periods = intervals.map { period -> + period.copy( + joinedDate = period.joinedDate ?: period.joinedAt?.atZone(zoneId)?.toLocalDate(), + leftDate = period.leftDate ?: period.leftAt?.atZone(zoneId)?.toLocalDate(), + ) + } periods.forEach { period -> if (!hasValidOwnChronology(period)) { return "Membership period ${period.id} does not end after it starts" @@ -39,8 +50,6 @@ internal object MembershipConsistencyPolicy { return null } - fun isActive(periods: List): Boolean = periods.any { it.leftAt == null } - private fun hasValidOwnChronology(period: MembershipInterval): Boolean { val joinedAt = period.joinedAt ?: return true val leftAt = period.leftAt ?: return true @@ -57,8 +66,8 @@ internal object MembershipConsistencyPolicy { private fun compareStarts(first: MembershipInterval, second: MembershipInterval): Int { val firstInstant = requireNotNull(first.joinedAt) val secondInstant = requireNotNull(second.joinedAt) - val firstDate = first.joinedDate ?: firstInstant.atZone(ZoneOffset.UTC).toLocalDate() - val secondDate = second.joinedDate ?: secondInstant.atZone(ZoneOffset.UTC).toLocalDate() + val firstDate = requireNotNull(first.joinedDate) + val secondDate = requireNotNull(second.joinedDate) val dateOrder = firstDate.compareTo(secondDate) if (dateOrder != 0) return dateOrder // Keep known instants contiguous and ordered before date-only boundaries. diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt new file mode 100644 index 0000000..691c362 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -0,0 +1,387 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.MembershipRows.insertEvent +import dev.gf2log.app.management.MembershipRows.joinEventType +import dev.gf2log.app.management.MembershipRows.memberExists +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Manual member and membership-period edits, each verified against the canonical timeline. */ +internal object MembershipEditing { + fun addWithdrawnMember( + db: SQLiteDatabase, + gameZone: () -> ZoneId, + uid: Long, + name: String, + joined: MembershipBoundaryValue, + withdrew: MembershipBoundaryValue, + note: String, + ): Boolean { + require(uid > 0) + val storedName = ManualTextPolicy.memberName(name) + val storedNote = ManualTextPolicy.note(note) + require(isValidMembershipRange(joined, withdrew)) + return db.inTransaction { + if (memberExists(db, uid)) return@inTransaction false + MembershipRows.insertMember( + db = db, + uid = uid, + name = storedName, + level = 0, + firstSeenAt = joined.instant, + lastSeenAt = withdrew.instant, + customName = storedName, + ) + val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, storedNote) + replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, storedName, joined, withdrew) + synchronizeAndVerifyMembershipState(db, gameZone(), uid) + true + } + } + + fun addMembershipPeriod( + db: SQLiteDatabase, + gameZone: () -> ZoneId, + uid: Long, + joined: MembershipBoundaryValue, + withdrew: MembershipBoundaryValue?, + note: String, + ): Boolean { + val storedNote = ManualTextPolicy.note(note) + require(isValidMembershipRange(joined, withdrew)) + return db.inTransaction { + val memberName = memberName(db, uid) ?: return@inTransaction false + if (!canStoreMembershipPeriod(db, gameZone(), uid, null, joined, withdrew)) { + return@inTransaction false + } + val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, storedNote) + replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) + synchronizeAndVerifyMembershipState(db, gameZone(), uid) + true + } + } + + fun updateMember(db: SQLiteDatabase, uid: Long, name: String, note: String): Boolean { + val values = ContentValues().apply { + put("custom_name", ManualTextPolicy.memberName(name)) + put("note", ManualTextPolicy.note(note)) + } + return db.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 + } + + fun deleteMember(db: SQLiteDatabase, uid: Long): Boolean { + require(uid > 0) + return db.inTransaction { + if (!memberExists(db, uid)) return@inTransaction false + val uidArgument = arrayOf(uid.toString()) + db.delete("weekly_overrides", "uid = ?", uidArgument) + db.delete( + "platoon_activity", + "resolved_uid = ? OR member_event_id IN " + + "(SELECT id FROM member_events WHERE uid = ?)", + arrayOf(uid.toString(), uid.toString()), + ) + db.delete( + "weekly_notes", + "is_automatic = 1 AND event_id IN " + + "(SELECT id FROM member_events WHERE uid = ?)", + uidArgument, + ) + db.delete("member_events", "uid = ?", uidArgument) + db.delete("membership_periods", "uid = ?", uidArgument) + db.delete("snapshot_members", "uid = ?", uidArgument) + val deleted = db.delete("members", "uid = ?", uidArgument) == 1 + check(deleted) { "Member disappeared during deletion" } + true + } + } + + /** + * Deletes one membership period and its derived boundary events atomically. The member's + * final period is protected; returns false when the period is missing or is the only one. + */ + fun deleteMembershipPeriod( + db: SQLiteDatabase, + gameZone: () -> ZoneId, + membershipPeriodId: Long, + ): Boolean { + require(membershipPeriodId > 0) + return db.inTransaction { + val uid = db.rawQuery( + "SELECT uid FROM membership_periods WHERE id = ?", + arrayOf(membershipPeriodId.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + ?: return@inTransaction false + val periodCount = db.rawQuery( + "SELECT COUNT(*) FROM membership_periods WHERE uid = ?", + arrayOf(uid.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getLong(0) + } + if (periodCount <= 1L) return@inTransaction false + // A vanished row after its events were detached must roll those deletions back. + if (!MembershipRows.deleteMembershipPeriod(db, membershipPeriodId)) return false + synchronizeAndVerifyMembershipState(db, gameZone(), uid) + true + } + } + + fun updateMembershipPeriod( + db: SQLiteDatabase, + gameZone: () -> ZoneId, + membershipPeriodId: Long, + joined: MembershipBoundaryValue, + left: MembershipBoundaryValue?, + note: String, + ): Boolean { + val storedNote = ManualTextPolicy.note(note) + return db.inTransaction { + val period = readMembershipPeriodForUpdate(db, membershipPeriodId, gameZone) + ?: return@inTransaction false + // Exact packet-linked boundaries stay immutable; only editable sides take the request. + val effectiveJoined = if (isImmutableMembershipBoundary(period.joined, period.joinedSource)) { + requireNotNull(period.joined) + } else { + joined + } + val effectiveLeft = if (isImmutableMembershipBoundary(period.left, period.leftSource)) { + period.left + } else { + left + } + require(isValidMembershipRange(effectiveJoined, effectiveLeft)) + if (!canStoreMembershipPeriod(db, gameZone(), period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { + return@inTransaction false + } + val joinedChanged = editableMembershipBoundaryChanged( + original = period.joined, + requested = joined, + source = period.joinedSource, + ) + val leftChanged = editableMembershipBoundaryChanged( + original = period.left, + requested = left, + source = period.leftSource, + ) + val updated = db.update( + "membership_periods", + ContentValues().apply { + if (joinedChanged) putManualBoundary("joined", joined) + if (leftChanged) putManualBoundary("left", left) + put("note", storedNote) + }, + "id = ?", + arrayOf(membershipPeriodId.toString()), + ) == 1 + if (updated && (joinedChanged || leftChanged)) { + val joinedIsManual = joinedChanged || period.joinedSource == EvidenceSource.MANUAL + val leftIsManual = if (leftChanged) { + left != null + } else { + period.leftSource == EvidenceSource.MANUAL + } + replaceManualMembershipPeriodEvents( + db, + membershipPeriodId, + period.uid, + memberName(db, period.uid).orEmpty(), + effectiveJoined.takeIf { joinedIsManual }, + effectiveLeft.takeIf { leftIsManual }, + ) + } + if (updated) synchronizeAndVerifyMembershipState(db, gameZone(), period.uid) + updated + } + } + + /** + * Evaluates a proposed manual period together with every persisted period for the member, + * rejecting overlaps and multiple open periods before any row changes. + */ + private fun canStoreMembershipPeriod( + db: SQLiteDatabase, + zoneId: ZoneId, + uid: Long, + replacedPeriodId: Long?, + joined: MembershipBoundaryValue, + left: MembershipBoundaryValue?, + ): Boolean { + val proposed = MembershipInterval( + id = replacedPeriodId ?: Long.MAX_VALUE, + joinedAt = joined.instant, + leftAt = left?.instant, + joinedDate = joined.date, + leftDate = left?.date, + joinedTimeKnown = joined.timeKnown, + leftTimeKnown = left?.timeKnown ?: true, + ) + val periods = readMembershipIntervals(db, uid) + .filterNot { it.id == replacedPeriodId } + proposed + return MembershipConsistencyPolicy.violation(periods, zoneId) == null + } + + /** + * Verifies chronology, derives current activity from the canonical periods, and verifies + * event ownership before the surrounding transaction may commit. + */ + private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, zoneId: ZoneId, uid: Long) { + val periods = readMembershipIntervals(db, uid) + checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } + val violation = MembershipConsistencyPolicy.violation(periods, zoneId) + check(violation == null) { violation.orEmpty() } + check(MembershipRows.synchronizeActiveState(db, listOf(uid)) == 1) { + "Membership state has no owning member" + } + val mismatchedEvent = db.rawQuery( + "SELECT 1 FROM member_events event " + + "JOIN membership_periods period ON period.id = event.membership_period_id " + + "WHERE event.uid != period.uid AND (event.uid = ? OR period.uid = ?) LIMIT 1", + arrayOf(uid.toString(), uid.toString()), + ).use(Cursor::moveToFirst) + check(!mismatchedEvent) { "Membership event belongs to a different member" } + } + + private fun readMembershipIntervals(db: SQLiteDatabase, uid: Long): List = + db.rawQuery( + "SELECT id, joined_at, left_at, joined_date, left_date, " + + "joined_time_known, left_time_known " + + "FROM membership_periods WHERE uid = ? ORDER BY id", + arrayOf(uid.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + MembershipInterval( + id = cursor.getLong(0), + joinedAt = cursor.getNullableInstant(1), + leftAt = cursor.getNullableInstant(2), + joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), + leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), + joinedTimeKnown = cursor.getNullableInt(5)?.let { it != 0 } ?: true, + leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 } ?: true, + ), + ) + } + } + } + + private fun insertManualMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + joined: MembershipBoundaryValue, + withdrew: MembershipBoundaryValue?, + storedNote: String, + ): Long = db.insertOrThrow( + "membership_periods", + null, + ContentValues().apply { + put("uid", uid) + putManualBoundary("joined", joined) + putManualBoundary("left", withdrew) + put("note", storedNote) + }, + ) + + /** A manual boundary, or a cleared one when [value] is null. */ + private fun ContentValues.putManualBoundary(prefix: String, value: MembershipBoundaryValue?) { + if (value == null) { + clearMembershipBoundary(prefix) + putNull("${prefix}_precision") + putNull("${prefix}_source") + } else { + putMembershipBoundary(prefix, value) + put("${prefix}_precision", EvidencePrecision.MANUAL.name) + put("${prefix}_source", EvidenceSource.MANUAL.name) + } + } + + private fun replaceManualMembershipPeriodEvents( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + joined: MembershipBoundaryValue?, + withdrew: MembershipBoundaryValue?, + ) { + db.delete( + "member_events", + "membership_period_id = ? AND source = ?", + arrayOf(membershipPeriodId.toString(), EvidenceSource.MANUAL.name), + ) + val observedAt = Instant.now() + listOfNotNull( + joined?.let { joinEventType(db, uid, membershipPeriodId) to it }, + withdrew?.let { MemberEventType.LEFT to it }, + ).forEach { (type, boundary) -> + insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = boundary.instant, + eventDate = boundary.date, + timeKnown = boundary.timeKnown, + observedAt = observedAt, + precision = EvidencePrecision.MANUAL, + source = EvidenceSource.MANUAL, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + } + } + + private fun memberName(db: SQLiteDatabase, uid: Long): String? = + db.rawQuery( + "SELECT COALESCE(custom_name, current_name) FROM members WHERE uid = ?", + arrayOf(uid.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + + private fun readMembershipPeriodForUpdate( + db: SQLiteDatabase, + membershipPeriodId: Long, + gameZone: () -> ZoneId, + ): MembershipPeriodForUpdate? = db.rawQuery( + """ + SELECT uid, joined_at, left_at, joined_date, left_date, + joined_time_known, left_time_known, joined_source, left_source + FROM membership_periods + WHERE id = ? + """.trimIndent(), + arrayOf(membershipPeriodId.toString()), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + val zoneId = gameZone() + MembershipPeriodForUpdate( + uid = cursor.getLong(0), + joined = cursor.membershipBoundaryValue( + instantIndex = 1, + dateIndex = 3, + timeKnownIndex = 5, + zoneId = zoneId, + ), + left = cursor.membershipBoundaryValue( + instantIndex = 2, + dateIndex = 4, + timeKnownIndex = 6, + zoneId = zoneId, + ), + joinedSource = EvidenceSource.valueOf(cursor.getString(7)), + leftSource = cursor.getNullableString(8)?.let(EvidenceSource::valueOf), + ) + } + } + + private data class MembershipPeriodForUpdate( + val uid: Long, + val joined: MembershipBoundaryValue?, + val left: MembershipBoundaryValue?, + val joinedSource: EvidenceSource, + val leftSource: EvidenceSource?, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt index b761f0e..54705b0 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt @@ -30,11 +30,29 @@ fun isValidMembershipRange( fun EvidenceSource.isImmutableMembershipBoundary(): Boolean = this == EvidenceSource.GAME_UPDATES +/** + * Only a recorded packet-linked boundary is immutable. A period opened by an exact Updates + * withdrawal has a GAME_UPDATES join source but no join time, and that unknown side stays + * editable. + */ +internal fun isImmutableMembershipBoundary( + value: MembershipBoundaryValue?, + source: EvidenceSource?, +): Boolean = value != null && source?.isImmutableMembershipBoundary() == true + +/** Whether the join side may be edited; see [isImmutableMembershipBoundary]. */ +val MembershipPeriod.isJoinedBoundaryEditable: Boolean + get() = joinedAt == null || !joinedSource.isImmutableMembershipBoundary() + +/** Whether the withdrawal side may be edited; see [isImmutableMembershipBoundary]. */ +val MembershipPeriod.isLeftBoundaryEditable: Boolean + get() = leftAt == null || leftSource?.isImmutableMembershipBoundary() != true + internal fun editableMembershipBoundaryChanged( original: MembershipBoundaryValue?, requested: MembershipBoundaryValue?, source: EvidenceSource?, -): Boolean = source?.isImmutableMembershipBoundary() != true && original != requested +): Boolean = !isImmutableMembershipBoundary(original, source) && original != requested internal fun migrationCalendarDate(epochMillis: Long, zoneId: ZoneId): LocalDate = Instant.ofEpochMilli(epochMillis).atZone(zoneId).toLocalDate() diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt new file mode 100644 index 0000000..20a93a6 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt @@ -0,0 +1,278 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import java.time.Instant +import java.time.LocalDate + +/** Row-level member, period, and event writes shared by every evidence path. */ +internal object MembershipRows { + fun memberExists(db: SQLiteDatabase, uid: Long): Boolean = + db.exists("SELECT 1 FROM members WHERE uid = ? LIMIT 1", uid) + + /** Inserts an inactive member; callers derive activity with [synchronizeActiveState]. */ + fun insertMember( + db: SQLiteDatabase, + uid: Long, + name: String, + level: Long, + firstSeenAt: Instant, + lastSeenAt: Instant, + customName: String? = null, + ) { + db.insertOrThrow( + "members", + null, + ContentValues().apply { + put("uid", uid) + put("current_name", name) + if (customName != null) put("custom_name", customName) + put("current_level", level) + put("is_active", 0) + put("first_seen_at", firstSeenAt.toEpochMilli()) + put("last_seen_at", lastSeenAt.toEpochMilli()) + put("note", "") + }, + ) + } + + fun insertMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + joinedAt: Instant?, + precision: EvidencePrecision, + source: EvidenceSource, + ): Long = db.insertOrThrow( + "membership_periods", + null, + ContentValues().apply { + put("uid", uid) + putNullableLong("joined_at", joinedAt?.toEpochMilli()) + put("joined_precision", precision.name) + put("joined_source", source.name) + }, + ) + + fun closeLatestMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + leftAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + ): Long { + val membershipPeriodId = db.rawQuery( + "SELECT id FROM membership_periods WHERE uid = ? AND left_at IS NULL ORDER BY id DESC LIMIT 1", + arrayOf(uid.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + ?: error("Active member $uid has no open membership period") + db.update( + "membership_periods", + ContentValues().apply { + put("left_at", leftAt.toEpochMilli()) + putNull("left_date") + put("left_time_known", 1) + put("left_precision", precision.name) + put("left_source", source.name) + }, + "id = ?", + arrayOf(membershipPeriodId.toString()), + ) + return membershipPeriodId + } + + fun ensureRosterActiveMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + source: EvidenceSource, + ) { + if (db.exists("${effectiveOpenPeriod("?")} LIMIT 1", uid)) return + insertMembershipPeriod( + db = db, + uid = uid, + joinedAt = null, + precision = EvidencePrecision.UNKNOWN, + source = source, + ) + } + + fun hasEarlierMembershipPeriod(db: SQLiteDatabase, uid: Long, membershipPeriodId: Long): Boolean = + db.exists( + """ + SELECT 1 + FROM membership_periods current + JOIN membership_periods other ON other.uid = current.uid AND other.id != current.id + WHERE current.id = ? + AND current.uid = ? + AND ( + (current.joined_at IS NOT NULL AND other.joined_at IS NOT NULL AND + (other.joined_at < current.joined_at OR + (other.joined_at = current.joined_at AND other.id < current.id))) + OR + (current.joined_at IS NULL AND other.id < current.id) + ) + LIMIT 1 + """.trimIndent(), + membershipPeriodId, + uid, + ) + + /** JOINED for a member's first period by join order, otherwise REJOINED. */ + fun joinEventType(db: SQLiteDatabase, uid: Long, membershipPeriodId: Long): MemberEventType = + if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { + MemberEventType.REJOINED + } else { + MemberEventType.JOINED + } + + fun insertEvent( + db: SQLiteDatabase, + uid: Long, + type: MemberEventType, + occurredAt: Instant?, + eventDate: LocalDate? = null, + timeKnown: Boolean = true, + observedAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + note: String, + membershipPeriodId: Long? = null, + ): Long = db.insertOrThrow( + "member_events", + null, + ContentValues().apply { + put("uid", uid) + putNullableLong("membership_period_id", membershipPeriodId) + put("event_type", type.name) + putNullableLong("occurred_at", occurredAt?.toEpochMilli()) + putNullableLong("event_date", eventDate?.toEpochDay()) + put("time_known", if (timeKnown) 1 else 0) + put("observed_at", observedAt.toEpochMilli()) + put("precision", precision.name) + put("source", source.name) + put("note", note) + }, + ) + + /** JOINED and REJOINED describe the same boundary, so either one satisfies the other. */ + fun compatibleEventTypes(type: MemberEventType): Array = when (type) { + MemberEventType.JOINED, + MemberEventType.REJOINED, + -> arrayOf(MemberEventType.JOINED.name, MemberEventType.REJOINED.name) + else -> arrayOf(type.name, type.name) + } + + fun insertSnapshotBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + observedAt: Instant, + source: EvidenceSource, + ) { + val compatibleTypes = compatibleEventTypes(type) + val exists = db.exists( + "SELECT 1 FROM member_events " + + "WHERE membership_period_id = ? AND event_type IN (?, ?) LIMIT 1", + membershipPeriodId, + compatibleTypes[0], + compatibleTypes[1], + ) + if (exists) return + insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = null, + observedAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + } + + /** Deletes the selected events after detaching every note and activity fact citing them. */ + fun deleteEvents(db: SQLiteDatabase, selection: String, arguments: Array) { + val selectedIds = "SELECT id FROM member_events WHERE $selection" + db.delete("weekly_notes", "is_automatic = 1 AND event_id IN ($selectedIds)", arguments) + db.update( + "weekly_notes", + ContentValues().apply { putNull("event_id") }, + "event_id IN ($selectedIds)", + arguments, + ) + db.update( + "platoon_activity", + ContentValues().apply { putNull("member_event_id") }, + "member_event_id IN ($selectedIds)", + arguments, + ) + db.delete("member_events", selection, arguments) + } + + /** Deletes one period with its events; returns whether the period row existed. */ + fun deleteMembershipPeriod(db: SQLiteDatabase, membershipPeriodId: Long): Boolean { + val id = arrayOf(membershipPeriodId.toString()) + deleteEvents(db, "membership_period_id = ?", id) + return db.delete("membership_periods", "id = ?", id) == 1 + } + + /** + * The one rule for `members.is_active`: a member is active while an effective open period + * remains. An open period started by an exact Updates join is superseded by later exit + * evidence: another period exiting after that join, or a newer authoritative roster that + * omits the member. When rosters and Updates agree this is simply "some period is open", + * the projection the schema-v13 upgrade repairs. + */ + private fun effectiveOpenPeriod(uid: String): String = + """ + SELECT 1 FROM membership_periods current_period + WHERE current_period.uid = $uid + AND current_period.left_at IS NULL + AND NOT ( + current_period.joined_source = '${EvidenceSource.GAME_UPDATES.name}' + AND ( + EXISTS ( + SELECT 1 FROM membership_periods later_exit + WHERE later_exit.uid = current_period.uid + AND later_exit.id != current_period.id + AND later_exit.left_at > current_period.joined_at + ) + OR EXISTS ( + SELECT 1 FROM snapshots latest_roster + WHERE latest_roster.captured_at = (SELECT MAX(captured_at) FROM snapshots) + AND latest_roster.captured_at > current_period.joined_at + AND NOT EXISTS ( + SELECT 1 + FROM snapshot_members roster_member + JOIN snapshots roster ON roster.id = roster_member.snapshot_id + WHERE roster_member.uid = current_period.uid + AND roster.captured_at = latest_roster.captured_at + ) + ) + ) + ) + """.trimIndent() + + private val ACTIVE_STATE_UPDATE = + "UPDATE members SET is_active = CASE WHEN EXISTS (${effectiveOpenPeriod("members.uid")}) " + + "THEN 1 ELSE 0 END" + + /** Re-derives the active flag of [uids] inside the caller's transaction; returns rows updated. */ + fun synchronizeActiveState(db: SQLiteDatabase, uids: Collection): Int = + uids.distinct().chunked(MAX_BOUND_ARGUMENTS).sumOf { chunk -> + db.compileStatement( + "$ACTIVE_STATE_UPDATE WHERE uid IN (${chunk.joinToString { "?" }})", + ).use { statement -> + chunk.forEachIndexed { index, uid -> statement.bindLong(index + 1, uid) } + statement.executeUpdateDelete() + } + } + + fun synchronizeAllActiveStates(db: SQLiteDatabase) { + db.execSQL(ACTIVE_STATE_UPDATE) + } + + private const val MAX_BOUND_ARGUMENTS = 500 +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt index 6c48162..dac6f2c 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -9,12 +9,12 @@ import dev.gf2log.app.settings.AppBackupSettingsCodec import dev.gf2log.app.settings.BackupSettingsStore import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.ScopedAppSettingsStore import java.io.EOFException import java.io.File import java.io.InputStream import java.io.OutputStream -import java.security.MessageDigest import java.util.zip.ZipException class PlatoonBackupManager internal constructor( @@ -62,20 +62,9 @@ class PlatoonBackupManager internal constructor( /** Returns a stable digest after closing SQLite so WAL state is checkpointed. */ internal fun currentDatabaseSha256(): String = PlatoonRepository.withExclusiveDatabase(storageScope) { - ensureDatabaseExists() - val digest = MessageDigest.getInstance("SHA-256") - databaseFile.inputStream().use { input -> - val buffer = ByteArray(8 * 1024) - while (true) { - val count = input.read(buffer) - if (count < 0) break - digest.update(buffer, 0, count) - } - } - digest.digest().joinToString("") { value -> - String.format("%02x", value.toInt() and 0xff) + ensureDatabaseExists() + databaseFile.sha256Hex() } - } fun exportFull(output: OutputStream) { PlatoonRepository(appContext, storageScope).reconcileRetainedCsvFiles() @@ -91,41 +80,17 @@ class PlatoonBackupManager internal constructor( } } - fun restore(input: InputStream) { - restoreDirectory.mkdirs() - val stagedDatabase = File(restoreDirectory, "platoon.db.staged") - if (stagedDatabase.exists() && !stagedDatabase.delete()) { - error("Unable to clear a previous staged restore") - } - val staged = validateSelectedBackup { - BackupArchive.stage(input, stagedDatabase) - } - try { - val target = managerFor(staged) - target.manager.restoreStagedPlatoon(stagedDatabase, staged, target) - } finally { - stagedDatabase.delete() - } + fun restore(input: InputStream) = withStagedArchive(input) { stagedDatabase, staged -> + val target = managerFor(staged) + target.manager.restoreStagedPlatoon(stagedDatabase, staged, target) } - // Function Name: restoreCheckpoint - // Description: - // - Restores a same-device, current-schema checkpoint without retiring retained CSV evidence. - // - Used only after the caller removes deterministic files planned by the reverted import. - // Parameters: - // - input: Checkpoint archive created by this application. - // Returns: - // - Unit after validated, crash-aware database replacement. - internal fun restoreCheckpoint(input: InputStream) { - restoreDirectory.mkdirs() - val stagedDatabase = File(restoreDirectory, "platoon.db.staged") - if (stagedDatabase.exists() && !stagedDatabase.delete()) { - error("Unable to clear a previous staged restore") - } - val staged = validateSelectedBackup { - BackupArchive.stage(input, stagedDatabase) - } - try { + /** + * Restores a same-device, current-schema checkpoint without retiring retained CSV evidence. + * Used only after the caller removes deterministic files planned by the reverted import. + */ + internal fun restoreCheckpoint(input: InputStream) = + withStagedArchive(input) { stagedDatabase, staged -> validateSelectedBackup { BackupFormatPolicy.requirePlatoonOnly( staged.formatVersion, @@ -135,23 +100,26 @@ class PlatoonBackupManager internal constructor( validateDatabase(stagedDatabase, requireCurrentSchema = true) } replaceRestoredState(stagedDatabase, restoredSettings = null, retireRetainedCsv = false) - } finally { - stagedDatabase.delete() } + + fun restoreFull(input: InputStream) = withStagedArchive(input) { stagedDatabase, staged -> + val target = managerFor(staged) + target.manager.restoreStagedComplete(stagedDatabase, staged, target) } - fun restoreFull(input: InputStream) { - restoreDirectory.mkdirs() + private inline fun withStagedArchive( + input: InputStream, + restoreStaged: (File, BackupArchive.StagedArchive) -> Unit, + ) { + check(restoreDirectory.mkdirs() || restoreDirectory.isDirectory) { + "Unable to create the restore staging directory" + } val stagedDatabase = File(restoreDirectory, "platoon.db.staged") if (stagedDatabase.exists() && !stagedDatabase.delete()) { error("Unable to clear a previous staged restore") } - val staged = validateSelectedBackup { - BackupArchive.stage(input, stagedDatabase) - } try { - val target = managerFor(staged) - target.manager.restoreStagedComplete(stagedDatabase, staged, target) + restoreStaged(stagedDatabase, stageSelectedArchive(input, stagedDatabase)) } finally { stagedDatabase.delete() } @@ -187,16 +155,14 @@ class PlatoonBackupManager internal constructor( private fun managerFor(staged: BackupArchive.StagedArchive): RestoreTarget { val registry = PlatoonProfileRegistry(appContext) - val restoredProfile = requireNotNull(staged.profile) { - "Unscoped legacy backups are no longer supported" - }.toProfile() - require(!restoredProfile.legacy) { "Unscoped legacy backups are no longer supported" } + val restoredProfile = staged.scopedProfile().toProfile() registry.requireRestoreCapacity(restoredProfile) val scope = PlatoonStorageScope(restoredProfile.storageId) val previousProfile = registry.find(scope.storageId) val previousActiveStorageId = registry.active()?.storageId val clientRegions = ClientServerRegionPreferences(appContext) val previousCaptureRegion = clientRegions.stored(restoredProfile.client.packageName) + val previousTimeZone = GameTimeZonePreferences.scopedStateOrNull(appContext, scope.storageId) val manager = if (scope == storageScope) { this } else { @@ -214,6 +180,7 @@ class PlatoonBackupManager internal constructor( previousActiveStorageId = previousActiveStorageId, registry = registry, previousCaptureRegion = previousCaptureRegion, + previousTimeZone = previousTimeZone, ) } @@ -224,6 +191,7 @@ class PlatoonBackupManager internal constructor( val previousActiveStorageId: String?, val registry: PlatoonProfileRegistry, val previousCaptureRegion: GameServerRegion?, + val previousTimeZone: GameTimeZonePreferences.ScopedZoneState?, ) { fun rollbackJournal(): PlatoonProfileRestoreJournal = PlatoonProfileRestoreJournal( targetStorageId = manager.storageScope.storageId, @@ -231,24 +199,33 @@ class PlatoonBackupManager internal constructor( previousActiveStorageId = previousActiveStorageId, ownerPackage = restoredProfile?.client?.packageName, previousCaptureRegion = previousCaptureRegion, + previousTimeZone = previousTimeZone, ) - fun installProfileMetadataAndActivate() { - val storageId = requireNotNull(restoredProfile) - .let(registry::upsertRestored) - .storageId + /** Installs the archived profile, then reconciles its reset-zone override with it. */ + fun installProfileMetadataAndActivate(restoredSettings: Boolean) { + val profile = requireNotNull(restoredProfile) + val storageId = registry.upsertRestored(profile).storageId check(registry.setActive(storageId)) { "Unable to select the restored Platoon" } + val context = manager.appContext + val zoneState = GameTimeZonePreferences.scopedState(context, storageId) + if ( + RestoredTimeZonePolicy.clearsOverride( + state = zoneState, + previousRegion = previousProfile?.serverRegion, + restoredRegion = profile.serverRegion, + restoredSettings = restoredSettings, + ) + ) { + GameTimeZonePreferences.clearRegionOverride(context, storageId) + } } } private fun requireArchiveMatchesScope(staged: BackupArchive.StagedArchive) { - val archivedProfile = requireNotNull(staged.profile) { - "Unscoped legacy backups are no longer supported" - } - require(!archivedProfile.legacy) { "Unscoped legacy backups are no longer supported" } - require(archivedProfile.storageId == storageScope.storageId) { + require(staged.scopedProfile().storageId == storageScope.storageId) { "Backup belongs to a different Platoon" } } @@ -261,50 +238,19 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: validateSelectedBackup - // Description: - // - Converts expected archive, settings, and SQLite validation failures into a typed error. - // - Leaves provider I/O and local restore-state failures distinct for accurate UI reporting. - // Parameters: - // - validation: Read-only validation operation for the selected backup. - // Returns: - // - Returns the validation result when the selected backup is valid. - private inline fun validateSelectedBackup(validation: () -> T): T = try { - validation() - } catch (error: IllegalArgumentException) { - throw InvalidBackupException(error) - } catch (error: SQLiteException) { - throw InvalidBackupException(error) - } catch (error: ZipException) { - throw InvalidBackupException(error) - } catch (error: EOFException) { - throw InvalidBackupException(error) - } - - // Function Name: replaceRestoredState - // Description: - // - Replaces the validated database and retires target-device roster CSV evidence atomically. - // - Replaces settings only for a complete backup while preserving them for a v1 backup. - // - Uses the durable restore journal so a process death or failure rolls every resource back. - // Parameters: - // - stagedDatabase: Validated database extracted from the selected backup. - // - restoredSettings: Complete-backup settings, or null for Platoon-only compatibility restore. - // Returns: - // - Returns normally after the restored state commits and rollback artifacts are cleaned. + /** + * Replaces the validated database and retires target-device roster CSV evidence atomically. + * Replaces settings only for a complete backup while preserving them for a v1 backup. Uses the + * durable restore journal so a process death or failure rolls every resource back. + */ private fun replaceRestoredState( stagedDatabase: File, restoredSettings: AppBackupSettings?, retireRetainedCsv: Boolean = true, restoreTarget: RestoreTarget? = null, ) { - val retainedCsvDirectory = File( - storageScope.rootDirectory(appContext), - PlatoonRepository.RETAINED_CSV_DIRECTORY, - ) - val previousRetainedCsvDirectory = File( - storageScope.rootDirectory(appContext), - "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", - ) + val retainedCsvDirectory = storageScope.retainedCsvDirectory(appContext) + val previousRetainedCsvDirectory = storageScope.previousRetainedCsvDirectory(appContext) try { PlatoonRepository.withExclusiveDatabase(storageScope) { beginRestoreTransaction( @@ -329,7 +275,7 @@ class PlatoonBackupManager internal constructor( ) restoreObserver(RestoreCheckpoint.RETAINED_CSV_RETIRED) } - restoreTarget?.installProfileMetadataAndActivate() + restoreTarget?.installProfileMetadataAndActivate(restoredSettings != null) if (restoreTarget != null) { restoreObserver(RestoreCheckpoint.PROFILE_METADATA_INSTALLED) } @@ -337,7 +283,7 @@ class PlatoonBackupManager internal constructor( restoreObserver(RestoreCheckpoint.COMMITTED) cleanupCommittedRestore( previousRetainedCsvDirectory, - previousDatabaseFile(), + previousDatabaseFile(appContext, storageScope), ) } } catch (error: Exception) { @@ -404,24 +350,20 @@ class PlatoonBackupManager internal constructor( } } + /** The reference schema lives in memory, so concurrent restores never share a file. */ private fun validateCurrentSchema(database: SQLiteDatabase) { - appContext.deleteDatabase(SCHEMA_REFERENCE_DATABASE) - try { - PlatoonDatabase(appContext, SCHEMA_REFERENCE_DATABASE, storageScope).use { helper -> - val expected = DatabaseSchemaContract.read(helper.readableDatabase) - val actual = DatabaseSchemaContract.read(database) - require(actual == expected) { - "Full backup database does not match the current Platoon schema" - } + PlatoonDatabase.inMemory(appContext, storageScope).use { helper -> + val expected = DatabaseSchemaContract.read(helper.readableDatabase) + val actual = DatabaseSchemaContract.read(database) + require(actual == expected) { + "Full backup database does not match the current Platoon schema" } - } finally { - appContext.deleteDatabase(SCHEMA_REFERENCE_DATABASE) } } private fun replaceDatabase(staged: File, preservePrevious: Boolean = false) { databaseFile.parentFile?.mkdirs() - val previous = previousDatabaseFile() + val previous = previousDatabaseFile(appContext, storageScope) if (previous.exists() && !previous.delete()) error("Unable to clear previous restore backup") listOf("-wal", "-shm", "-journal").forEach { suffix -> File(databaseFile.path + suffix).delete() @@ -463,11 +405,6 @@ class PlatoonBackupManager internal constructor( } } - private fun previousDatabaseFile() = File( - databaseFile.parentFile, - "${storageScope.databaseName}.pre_restore", - ) - private fun ensureDatabaseExists() { if (databaseFile.isFile) return PlatoonDatabase(appContext, storageScope.databaseName).use { helper -> @@ -485,15 +422,10 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: beginRestoreTransaction - // Description: - // - Journals only the resources that the selected backup format can replace. - // - Keeps v1 database-only restore independent from app settings validation. - // Parameters: - // - previousSettings: Settings rollback value for complete restore, or null for v1 restore. - // - databaseExisted: Whether rollback must restore a previous database or remove a new one. - // Returns: - // - Returns normally after the durable PREPARED transaction state is written. + /** + * Journals only the resources that the selected backup format can replace. Keeps v1 + * database-only restore independent from app settings validation. + */ private fun beginRestoreTransaction( previousSettings: AppBackupSettings?, databaseExisted: Boolean, @@ -507,7 +439,6 @@ class PlatoonBackupManager internal constructor( restoreSettingsFile(appContext, storageScope), AppBackupSettingsCodec.encode(previousSettings), ) - writeAtomic(restoreSettingsRollbackFile(appContext, storageScope), ByteArray(0)) } if (!databaseExisted) { writeAtomic(restoreDatabaseWasMissingFile(appContext, storageScope), ByteArray(0)) @@ -539,61 +470,80 @@ class PlatoonBackupManager internal constructor( companion object { const val FILE_EXTENSION = "gf2backup" const val MIME_TYPE = "application/vnd.dev.gf2log.backup" - private const val SCHEMA_REFERENCE_DATABASE = "platoon-schema-reference.db" private const val RESTORE_TRANSACTION_DIRECTORY = "platoon-full-restore" private const val RESTORE_STATE_FILE = "state" private const val RESTORE_SETTINGS_FILE = "settings.pre_restore" + /** Written by older versions after the snapshot; now only removed during cleanup. */ private const val RESTORE_SETTINGS_ROLLBACK_FILE = "settings.rollback_required" private const val RESTORE_DATABASE_WAS_MISSING_FILE = "database.was_missing" private const val RESTORE_PROFILE_FILE = "profile.pre_restore" private val SQLITE_HEADER = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) - /** Restores a scoped archive even when a fresh installation has no active profile. */ + /** + * Restores a scoped archive even when a fresh installation has no active profile. + * [complete] forces the kind; null restores app settings exactly when the archive + * carries restorable settings. Returns the kind that was restored. + */ fun restoreSelected( context: Context, input: InputStream, - complete: Boolean, - ) { + complete: Boolean? = null, + ): RestoredBackupKind { val appContext = context.applicationContext val stagingDirectory = File(appContext.cacheDir, "platoon-restore-selected").apply { check(mkdirs() || isDirectory) { "Unable to create the restore staging directory" } } val stagedDatabase = File.createTempFile("selected-", ".db", stagingDirectory) try { - val staged = try { - BackupArchive.stage(input, stagedDatabase) - } catch (error: IllegalArgumentException) { - throw InvalidBackupException(error) - } catch (error: ZipException) { - throw InvalidBackupException(error) - } catch (error: EOFException) { - throw InvalidBackupException(error) - } - val profile = try { - requireNotNull(staged.profile) { - "Unscoped legacy backups are no longer supported" - }.toProfile().also { - require(!it.legacy) { "Unscoped legacy backups are no longer supported" } - } - } catch (error: IllegalArgumentException) { - throw InvalidBackupException(error) - } + val staged = stageSelectedArchive(input, stagedDatabase) + val profile = validateSelectedBackup { staged.scopedProfile().toProfile() } val manager = PlatoonBackupManager( appContext, PlatoonStorageScope(profile.storageId), ) val target = manager.managerFor(staged) - if (complete) { - manager.restoreStagedComplete(stagedDatabase, staged, target) - } else { - manager.restoreStagedPlatoon(stagedDatabase, staged, target) + val kind = BackupFormatPolicy.restoredKind( + staged.formatVersion, + staged.settings != null, + complete, + ) + when (kind) { + RestoredBackupKind.COMPLETE -> + manager.restoreStagedComplete(stagedDatabase, staged, target) + RestoredBackupKind.PLATOON -> + manager.restoreStagedPlatoon(stagedDatabase, staged, target) } + return kind } finally { stagedDatabase.delete() stagingDirectory.delete() } } + /** + * Converts expected archive, settings, and SQLite validation failures into a typed error. + * Provider I/O and local restore-state failures stay distinct for accurate UI reporting. + */ + private inline fun validateSelectedBackup(validation: () -> T): T = try { + validation() + } catch (error: IllegalArgumentException) { + throw InvalidBackupException(error) + } catch (error: SQLiteException) { + throw InvalidBackupException(error) + } catch (error: ZipException) { + throw InvalidBackupException(error) + } catch (error: EOFException) { + throw InvalidBackupException(error) + } + + private fun stageSelectedArchive(input: InputStream, stagedDatabase: File) = + validateSelectedBackup { BackupArchive.stage(input, stagedDatabase) } + + private fun BackupArchive.StagedArchive.scopedProfile(): BackupArchive.BackupPlatoonProfile = + requireNotNull(profile) { "Unscoped legacy backups are no longer supported" }.also { + require(!it.legacy) { "Unscoped legacy backups are no longer supported" } + } + internal fun recoverInterruptedFullRestore( context: Context, settingsStore: BackupSettingsStore? = null, @@ -648,6 +598,9 @@ class PlatoonBackupManager internal constructor( settingsStore: BackupSettingsStore, scope: PlatoonStorageScope, ) { + // Constructing a repository or manager must not take the exclusive gate (and evict + // the cached helper) unless a durable restore artifact actually needs recovery. + if (!hasPendingRestoreWork(appContext, scope)) return PlatoonRepository.withExclusiveDatabase(scope) { val stateFile = restoreStateFile(appContext, scope) if (!stateFile.isFile) { @@ -660,18 +613,9 @@ class PlatoonBackupManager internal constructor( RestoreState.valueOf(stateFile.readText(Charsets.US_ASCII)) }.getOrElse { throw IllegalStateException("Invalid backup-restore transaction", it) } val database = appContext.getDatabasePath(scope.databaseName) - val previousDatabase = File( - database.parentFile, - "${scope.databaseName}.pre_restore", - ) - val retainedCsv = File( - scope.rootDirectory(appContext), - PlatoonRepository.RETAINED_CSV_DIRECTORY, - ) - val previousCsv = File( - scope.rootDirectory(appContext), - "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", - ) + val previousDatabase = previousDatabaseFile(appContext, scope) + val retainedCsv = scope.retainedCsvDirectory(appContext) + val previousCsv = scope.previousRetainedCsvDirectory(appContext) when (state) { RestoreState.PREPARED -> rollbackPreparedRestore( appContext, @@ -715,10 +659,10 @@ class PlatoonBackupManager internal constructor( } } } + // A missing snapshot means nothing to replay: it is written before the restore can + // touch settings, and is deleted only by cleanup after a completed rollback. val settingsFile = restoreSettingsFile(context, scope) - val settingsRollback = restoreSettingsRollbackFile(context, scope) - if (settingsRollback.isFile || settingsFile.isFile) { - require(settingsFile.isFile) { "Previous app settings are missing" } + if (settingsFile.isFile) { settingsStore.replace(AppBackupSettingsCodec.decode(settingsFile.readBytes())) } if (previousCsv.exists()) { @@ -749,6 +693,10 @@ class PlatoonBackupManager internal constructor( previousProfile = rollback.previousProfile, previousActiveStorageId = rollback.previousActiveStorageId, ) + // Runs after the settings replay above, which can only write effective values. + rollback.previousTimeZone?.let { state -> + GameTimeZonePreferences.restoreScopedState(context, rollback.targetStorageId, state) + } } cleanupRestoreTransaction(context, scope) } @@ -757,14 +705,8 @@ class PlatoonBackupManager internal constructor( context: Context, scope: PlatoonStorageScope, ) { - val directory = File( - scope.rootDirectory(context), - PlatoonRepository.RETAINED_CSV_DIRECTORY, - ) - val previous = File( - scope.rootDirectory(context), - "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", - ) + val directory = scope.retainedCsvDirectory(context) + val previous = scope.previousRetainedCsvDirectory(context) if (!previous.exists()) return require(!directory.exists()) { "Both retained CSV state and a previous restore copy exist" @@ -774,33 +716,37 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: cleanupLegacyRetiredCsvArtifacts - // Description: - // - Removes obsolete cache-only retained-CSV copies left by pre-v2 restore code. - // - Runs only when no current full-restore transaction state exists. - // Parameters: - // - context: Application context used to locate the private restore cache. - // Returns: - // - Returns normally after all obsolete artifacts are removed. + /** Removes obsolete cache-only retained-CSV copies left by pre-v2 restore code. */ private fun cleanupLegacyRetiredCsvArtifacts( context: Context, scope: PlatoonStorageScope, ) { - val restoreDirectories = buildList { - add(File(context.cacheDir, "platoon-restore/${scope.storageId}")) - if (scope.isLegacy) add(File(context.cacheDir, "platoon-restore")) - } - restoreDirectories.flatMap { it.listFiles().orEmpty().asList() } - .filter { file -> - file.isDirectory && file.name.startsWith("guild-members.retired-") - } - .forEach { directory -> - check(directory.deleteRecursively()) { - "Unable to remove an obsolete retained CSV restore artifact" - } + legacyRetiredCsvArtifacts(context, scope).forEach { directory -> + check(directory.deleteRecursively()) { + "Unable to remove an obsolete retained CSV restore artifact" } + } } + private fun legacyRetiredCsvArtifacts( + context: Context, + scope: PlatoonStorageScope, + ): List = buildList { + add(File(context.cacheDir, "platoon-restore/${scope.storageId}")) + if (scope.isLegacy) add(File(context.cacheDir, "platoon-restore")) + }.flatMap { it.listFiles().orEmpty().asList() } + .filter { file -> file.isDirectory && file.name.startsWith("guild-members.retired-") } + + private fun hasPendingRestoreWork(context: Context, scope: PlatoonStorageScope): Boolean = + restoreTransactionDirectory(context, scope).exists() || + scope.previousRetainedCsvDirectory(context).exists() || + legacyRetiredCsvArtifacts(context, scope).isNotEmpty() + + private fun previousDatabaseFile(context: Context, scope: PlatoonStorageScope) = File( + context.getDatabasePath(scope.databaseName).parentFile, + "${scope.databaseName}.pre_restore", + ) + private fun writeAtomic(file: File, bytes: ByteArray) { file.parentFile?.mkdirs() val atomicFile = AtomicFile(file) @@ -823,9 +769,10 @@ class PlatoonBackupManager internal constructor( val state = restoreStateFile(context, scope) val databaseWasMissing = restoreDatabaseWasMissingFile(context, scope) val profile = restoreProfileFile(context, scope) + // The state goes first: without it, an interrupted cleanup only finishes deleting. + state.delete() settings.delete() settingsRollback.delete() - state.delete() databaseWasMissing.delete() profile.delete() directory.delete() @@ -867,11 +814,15 @@ class PlatoonBackupManager internal constructor( } } -// Class Name: InvalidBackupException -// Role: Identifies failures that prove the selected backup is invalid. -// Responsibilities: -// - Error classification: Separates backup validation failures from operational restore errors. -// Attributes: -// - cause: Validation exception raised while reading the selected backup. +/** What [PlatoonBackupManager.restoreSelected] restored. */ +enum class RestoredBackupKind { + /** The Platoon database only; app settings were left untouched. */ + PLATOON, + + /** The Platoon database together with app and Platoon settings. */ + COMPLETE, +} + +/** A failure proving the selected backup invalid, as opposed to an operational restore error. */ internal class InvalidBackupException(cause: Exception) : Exception("Selected backup failed validation", cause) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt index ff19aed..d449eea 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -11,21 +11,17 @@ import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.security.MessageDigest import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter /** Validates and durably retains user-selected Platoon roster CSV evidence. */ class PlatoonCsvImportStore(private val directory: File) { - // Function Name: prepare - // Description: - // - Reads a bounded UTF-8 roster CSV, validates its complete schema, and hashes its bytes. - // - Does not create files, allowing callers to preview a whole selection before confirmation. - // Parameters: - // - input: User-selected document stream owned by the caller. - // Returns: - // - An immutable prepared import containing only validated data and its stable identity. + /** + * Reads a bounded UTF-8 roster CSV, validates its complete schema, and hashes its bytes. Does + * not create files, allowing callers to preview a whole selection before confirmation. Returns + * an immutable prepared import containing only validated data and its stable identity. + */ fun prepare(input: InputStream): PreparedImport { val bytes = readBounded(input) val text = StandardCharsets.UTF_8.newDecoder() @@ -36,9 +32,7 @@ class PlatoonCsvImportStore(private val directory: File) { val parsed = requireNotNull(GuildMembersCsv.parse(text)) { "Invalid Platoon CSV" } require(parsed.members.isNotEmpty()) { "Platoon CSV contains no roster members" } val capturedAt = Instant.parse(parsed.logTime) - val hash = MessageDigest.getInstance("SHA-256").digest(bytes) - .take(HASH_BYTES) - .joinToString("") { "%02x".format(it) } + val hash = bytes.sha256().copyOf(HASH_BYTES).toHex() return PreparedImport( encoded = bytes, snapshot = parsed, @@ -49,14 +43,11 @@ class PlatoonCsvImportStore(private val directory: File) { fun retain(input: InputStream): RetainResult = retain(prepare(input)) - // Function Name: retain - // Description: - // - Publishes a previously validated import under its deterministic identity. - // - Uses a durable temporary write and rejects a content-hash identity collision. - // Parameters: - // - prepared: Validated content returned by prepare. - // Returns: - // - Stable retained file identity and whether identical bytes already existed. + /** + * Publishes a previously validated import under its deterministic identity. Uses a durable + * temporary write and rejects a content-hash identity collision. Returns stable retained file + * identity and whether identical bytes already existed. + */ fun retain(prepared: PreparedImport): RetainResult { val bytes = prepared.encoded val capturedAt = prepared.capturedAt @@ -96,13 +87,6 @@ class PlatoonCsvImportStore(private val directory: File) { return RetainResult(file, duplicate = false) } - fun isRetained(prepared: PreparedImport): Boolean { - val file = File(directory, prepared.fileName) - if (!file.isFile) return false - require(file.readBytes().contentEquals(prepared.encoded)) { "CSV identity collision" } - return true - } - private fun readBounded(input: InputStream): ByteArray { val output = ByteArrayOutputStream() val buffer = ByteArray(READ_BUFFER_BYTES) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt index 5772106..d15f281 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -1,22 +1,22 @@ package dev.gf2log.app.management -import android.content.ContentValues import android.content.Context -import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import dev.gf2log.app.settings.GameTimeZonePreferences -import dev.gf2log.protocol.GuildMembersCsv import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.util.Locale -internal class PlatoonDatabase( +/** + * The single transactional write owner of one scoped management database. Every public method + * is serialized on this helper; the SQL lives in focused objects that receive the open + * [SQLiteDatabase] so one evidence ingest still commits or rolls back as a whole. + */ +internal class PlatoonDatabase private constructor( context: Context, - databaseName: String = PlatoonProfileRegistry(context).activeScope().databaseName, - private val storageScope: PlatoonStorageScope = - PlatoonStorageScope.fromDatabaseName(databaseName), + private val storageScope: PlatoonStorageScope, + databaseName: String?, ) : SQLiteOpenHelper( context.applicationContext, @@ -24,7 +24,16 @@ internal class PlatoonDatabase( null, PlatoonSchema.CURRENT_VERSION, ) { + constructor( + context: Context, + databaseName: String = PlatoonProfileRegistry(context).activeScope().databaseName, + storageScope: PlatoonStorageScope = PlatoonStorageScope.fromDatabaseName(databaseName), + ) : this(context, storageScope, databaseName) + private val appContext = context.applicationContext + + private fun gameZone(): ZoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId) + override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) db.setForeignKeyConstraintsEnabled(true) @@ -33,358 +42,31 @@ internal class PlatoonDatabase( override fun onOpen(db: SQLiteDatabase) { super.onOpen(db) if (!db.isReadOnly) { - createPlatoonMaintenanceStateTable(db) - createPlatoonActivityRetentionIndex(db) - createWeeklyReportHistoryTables(db) - trimPlatoonActivity(db) - } - } - - override fun onCreate(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - captured_at INTEGER NOT NULL, - source_file TEXT UNIQUE, - game_version TEXT - ) - """.trimIndent(), - ) - db.execSQL("CREATE INDEX snapshots_captured_at ON snapshots(captured_at DESC)") - db.execSQL( - """ - CREATE TABLE snapshot_members ( - snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE, - uid INTEGER NOT NULL, - name TEXT NOT NULL, - level INTEGER NOT NULL, - weekly_merit INTEGER NOT NULL, - total_merit INTEGER NOT NULL, - high_score INTEGER NOT NULL, - total_score INTEGER NOT NULL, - last_login INTEGER NOT NULL, - PRIMARY KEY(snapshot_id, uid) - ) - """.trimIndent(), - ) - db.execSQL( - """ - CREATE TABLE members ( - uid INTEGER PRIMARY KEY, - current_name TEXT NOT NULL, - custom_name TEXT, - current_level INTEGER NOT NULL, - is_active INTEGER NOT NULL, - first_seen_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - db.execSQL( - """ - CREATE TABLE membership_periods ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER NOT NULL REFERENCES members(uid), - joined_at INTEGER, - left_at INTEGER, - joined_date INTEGER, - left_date INTEGER, - joined_time_known INTEGER NOT NULL DEFAULT 1, - left_time_known INTEGER, - joined_precision TEXT NOT NULL, - left_precision TEXT, - joined_source TEXT NOT NULL, - left_source TEXT, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - db.execSQL("CREATE INDEX membership_periods_uid ON membership_periods(uid, id DESC)") - db.execSQL( - """ - CREATE TABLE member_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER NOT NULL REFERENCES members(uid), - membership_period_id INTEGER REFERENCES membership_periods(id), - event_type TEXT NOT NULL, - occurred_at INTEGER, - event_date INTEGER, - time_known INTEGER NOT NULL DEFAULT 1, - observed_at INTEGER NOT NULL, - precision TEXT NOT NULL, - source TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - db.execSQL("CREATE INDEX member_events_time ON member_events(observed_at DESC)") - createPlatoonActivityTable(db) - db.execSQL( - """ - CREATE TABLE weekly_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - period_start INTEGER NOT NULL, - game_day INTEGER NOT NULL, - text TEXT NOT NULL, - event_id INTEGER REFERENCES member_events(id) ON DELETE SET NULL, - is_automatic INTEGER NOT NULL DEFAULT 0 - ) - """.trimIndent(), - ) - createWeeklyOverridesTable(db) - createPlatoonMaintenanceStateTable(db) - createWeeklyReportHistoryTables(db) - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 10) { - db.execSQL("ALTER TABLE tenures RENAME TO membership_periods") - db.execSQL("DROP INDEX IF EXISTS tenures_uid") - db.execSQL( - "CREATE INDEX membership_periods_uid ON membership_periods(uid, id DESC)", - ) - if (oldVersion >= 6) { - migrateMembershipPeriodEventReference( - db = db, - hasCalendarColumns = oldVersion >= 9, - ) - } - } - val needsManualCalendarDateBackfill = oldVersion < 9 - if (oldVersion < 9) { - db.execSQL("ALTER TABLE membership_periods ADD COLUMN joined_date INTEGER") - db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_date INTEGER") - db.execSQL( - "ALTER TABLE membership_periods ADD COLUMN joined_time_known INTEGER NOT NULL DEFAULT 1", - ) - db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_time_known INTEGER") - db.execSQL("ALTER TABLE member_events ADD COLUMN event_date INTEGER") - db.execSQL( - "ALTER TABLE member_events ADD COLUMN time_known INTEGER NOT NULL DEFAULT 1", - ) - } - if (oldVersion < 2) { - val correctedPeriods = mutableListOf>() - db.query( - "weekly_notes", - arrayOf("id", "game_day"), - null, - null, - null, - null, - null, - ).use { cursor -> - while (cursor.moveToNext()) { - correctedPeriods += cursor.getLong(0) to - PlatoonPeriods.weekStart( - java.time.LocalDate.ofEpochDay(cursor.getLong(1)), - ).toEpochDay() - } - } - correctedPeriods.forEach { (id, periodStart) -> - db.update( - "weekly_notes", - ContentValues().apply { put("period_start", periodStart) }, - "id = ?", - arrayOf(id.toString()), - ) - } - } - if (oldVersion < 3) createWeeklyOverridesTable(db) - if (oldVersion < 4) { - db.execSQL("ALTER TABLE members ADD COLUMN custom_name TEXT") - } - if (oldVersion < 5) { - val historicalNames = mutableListOf>() - db.query( - "weekly_notes", - arrayOf("event_id", "text"), - "is_automatic = 1 AND event_id IS NOT NULL", - null, - null, - null, - "id", - ).use { cursor -> - while (cursor.moveToNext()) { - val parts = cursor.getString(1).split(':', limit = 3) - if (parts.size == 3 && parts[1].isNotBlank()) { - historicalNames += cursor.getLong(0) to parts[1] - } - } - } - historicalNames.forEach { (eventId, name) -> - db.update( - "member_events", - ContentValues().apply { put("note", name) }, - "id = ? AND note = ''", - arrayOf(eventId.toString()), - ) - } - } - if (oldVersion < 6) { - db.execSQL("ALTER TABLE member_events ADD COLUMN membership_period_id INTEGER REFERENCES membership_periods(id)") - createPlatoonActivityTable(db) - db.delete("weekly_notes", "is_automatic = 1", null) - linkLegacyMembershipPeriodEvents(db) + PlatoonDatabaseSchema.ensureMaintenanceObjects(db) + ActivityIngestion.trimPlatoonActivity(db) } - if (oldVersion < 7) { - backfillSnapshotMembershipPeriodEvents(db) - } - if (needsManualCalendarDateBackfill) { - backfillManualCalendarDates( - db, - GameTimeZonePreferences.get(appContext, storageScope.storageId), - ) - } - if (oldVersion < 11) { - createPlatoonMaintenanceStateTable(db) - createPlatoonActivityRetentionIndex(db) - } - if (oldVersion < 12) createWeeklyReportHistoryTables(db) - if (oldVersion < 13) synchronizeAllMembershipStates(db) } - private fun createWeeklyReportHistoryTables(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS weekly_report_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - period_start INTEGER NOT NULL, - recorded_at INTEGER NOT NULL, - fingerprint TEXT NOT NULL, - report_blob BLOB NOT NULL - ) - """.trimIndent(), - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS weekly_report_history_period " + - "ON weekly_report_history(period_start, recorded_at DESC, id DESC)", - ) - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS weekly_report_history_state ( - period_start INTEGER PRIMARY KEY, - active_history_id INTEGER REFERENCES weekly_report_history(id) ON DELETE SET NULL - ) - """.trimIndent(), - ) - } + override fun onCreate(db: SQLiteDatabase) = PlatoonDatabaseSchema.create(db) - // Function Name: migrateMembershipPeriodEventReference - // Description: - // - Rebuilds the membership-event table with the clearer membership-period identifier. - // - Rebuilds dependent tables so every foreign key targets the replacement event table. - // Parameters: - // - db: Database being upgraded inside SQLiteOpenHelper's transaction. - // - hasCalendarColumns: Whether the source event table already contains schema-v9 dates. - // Returns: - // - Returns after all rows, IDs, and foreign-key relationships have been preserved. - private fun migrateMembershipPeriodEventReference( - db: SQLiteDatabase, - hasCalendarColumns: Boolean, - ) { - db.execSQL("ALTER TABLE member_events RENAME TO member_events_legacy_v10") - db.execSQL("ALTER TABLE weekly_notes RENAME TO weekly_notes_legacy_v10") - db.execSQL("ALTER TABLE platoon_activity RENAME TO platoon_activity_legacy_v10") - db.execSQL("DROP INDEX IF EXISTS member_events_time") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_member_time") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_action_time") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_exact_identity") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_unresolved_identity") - - val calendarColumns = if (hasCalendarColumns) { - "event_date INTEGER, time_known INTEGER NOT NULL DEFAULT 1," - } else { - "" - } - db.execSQL( - """ - CREATE TABLE member_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER NOT NULL REFERENCES members(uid), - membership_period_id INTEGER REFERENCES membership_periods(id), - event_type TEXT NOT NULL, - occurred_at INTEGER, - $calendarColumns - observed_at INTEGER NOT NULL, - precision TEXT NOT NULL, - source TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - val calendarNames = if (hasCalendarColumns) ", event_date, time_known" else "" - db.execSQL( - """ - INSERT INTO member_events( - id, uid, membership_period_id, event_type, occurred_at$calendarNames, - observed_at, precision, source, note - ) - SELECT id, uid, tenure_id, event_type, occurred_at$calendarNames, - observed_at, precision, source, note - FROM member_events_legacy_v10 - """.trimIndent(), - ) - db.execSQL("CREATE INDEX member_events_time ON member_events(observed_at DESC)") + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + PlatoonDatabaseMigrations.upgrade(db, oldVersion, ::gameZone) - db.execSQL( - """ - CREATE TABLE weekly_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - period_start INTEGER NOT NULL, - game_day INTEGER NOT NULL, - text TEXT NOT NULL, - event_id INTEGER REFERENCES member_events(id) ON DELETE SET NULL, - is_automatic INTEGER NOT NULL DEFAULT 0 - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO weekly_notes(id, period_start, game_day, text, event_id, is_automatic) - SELECT id, period_start, game_day, text, event_id, is_automatic - FROM weekly_notes_legacy_v10 - """.trimIndent(), - ) + @Synchronized + internal fun runInTransaction(block: () -> T): T = writableDatabase.inTransaction(block) - createPlatoonActivityTable(db) - db.execSQL( - """ - INSERT OR IGNORE INTO platoon_activity( - id, occurred_at, action_id, kind, member_name, captured_at, - resolved_uid, resolution, member_event_id - ) - SELECT id, occurred_at, action_id, kind, member_name, captured_at, - resolved_uid, resolution, member_event_id - FROM platoon_activity_legacy_v10 - ORDER BY CASE WHEN member_event_id IS NULL THEN 1 ELSE 0 END, id - """.trimIndent(), - ) - db.execSQL("DROP TABLE weekly_notes_legacy_v10") - db.execSQL("DROP TABLE platoon_activity_legacy_v10") - db.execSQL("DROP TABLE member_events_legacy_v10") - } + /** Runs several reads against one state: every writer is serialized on this helper. */ + @Synchronized + internal fun readConsistently(block: () -> T): T = block() - /** Returns true only when this database contains user-visible management state. */ - internal fun hasManagementData(): Boolean { - val db = readableDatabase - return MANAGEMENT_DATA_TABLES.any { table -> count(db, table) > 0L } - } + @Synchronized + fun membershipPeriodOwner(membershipPeriodId: Long): Long? = + PlatoonReadQueries.membershipPeriodOwner(readableDatabase, membershipPeriodId) + /** Period starts of manual weekly notes that cite one of [uid]'s membership events. */ @Synchronized - internal fun runInTransaction(block: () -> T): T { - val db = writableDatabase - db.beginTransaction() - try { - val result = block() - db.setTransactionSuccessful() - return result - } finally { - db.endTransaction() - } - } + fun weeklyNotePeriodsCitingMember(uid: Long): Set = + WeeklyAnnotationStore.notePeriodsCitingMember(readableDatabase, uid) @Synchronized fun ingestSnapshot( @@ -392,1496 +74,51 @@ internal class PlatoonDatabase( source: EvidenceSource, historicalOnly: Boolean = false, deferHistoricalMembershipReconciliation: Boolean = false, - ): SnapshotIngestResult { - require(snapshot.members.isNotEmpty()) { "A Platoon snapshot cannot be empty" } - require(snapshot.members.size <= GuildMembersCsv.MAX_ROSTER_MEMBERS) { - "A Platoon snapshot exceeds the member limit" - } - require(snapshot.members.all { it.name.length <= GuildMembersCsv.MAX_MEMBER_NAME_CHARS }) { - "A Platoon snapshot contains an oversized member name" - } - require(snapshot.members.map(SnapshotMember::uid).distinct().size == snapshot.members.size) { - "A Platoon snapshot cannot contain duplicate UIDs" - } - - val db = writableDatabase - db.beginTransaction() - try { - if (snapshot.sourceFile != null && sourceFileExists(db, snapshot.sourceFile)) { - return SnapshotIngestResult.duplicate() - } - - val hasPriorSnapshot = count(db, "snapshots") > 0 - val priorCapturedAt = latestSnapshotCapturedAt(db) - if (historicalOnly) { - require(priorCapturedAt != null && !snapshot.capturedAt.isAfter(priorCapturedAt)) { - "Historical snapshots must not be newer than current structured data" - } - val snapshotId = insertSnapshotRows(db, snapshot) - if (!deferHistoricalMembershipReconciliation) { - reconcileSnapshotMembershipHistory(db) - } - db.setTransactionSuccessful() - return SnapshotIngestResult.historical(snapshotId) - } - val known = readKnownMembers(db) - val changes = SnapshotReconciler.reconcile( - known = known, - incoming = snapshot.members, - hasPriorSnapshot = hasPriorSnapshot, - ) - val snapshotId = db.insertOrThrow( - "snapshots", - null, - ContentValues().apply { - put("captured_at", snapshot.capturedAt.toEpochMilli()) - put("source_file", snapshot.sourceFile) - put("game_version", snapshot.gameVersion) - }, - ) - - snapshot.members.forEach { member -> - insertSnapshotMember(db, snapshotId, member) - upsertMember(db, member, snapshot.capturedAt) - } - - if (hasPriorSnapshot) { - val changedNameCounts = ( - changes.joined.map(SnapshotMember::name) + - changes.rejoined.map(SnapshotMember::name) + - changes.left.map(SnapshotReconciler.KnownMember::name) - ).groupingBy { it.lowercase(Locale.ROOT) }.eachCount() - val rosterNameUidCounts = ( - known.map { it.uid to it.name } + - snapshot.members.map { it.uid to it.name } - ).groupBy { it.second.lowercase(Locale.ROOT) } - .mapValues { (_, identities) -> identities.map { it.first }.distinct().size } - changes.joined.forEach { member -> - recordSnapshotJoin( - db = db, - member = member, - type = MemberEventType.JOINED, - observedAt = snapshot.capturedAt, - priorCapturedAt = priorCapturedAt, - source = source, - nameIsUnique = changedNameCounts[member.name.lowercase(Locale.ROOT)] == 1 && - rosterNameUidCounts[member.name.lowercase(Locale.ROOT)] == 1, - ) - } - changes.rejoined.forEach { member -> - recordSnapshotJoin( - db = db, - member = member, - type = MemberEventType.REJOINED, - observedAt = snapshot.capturedAt, - priorCapturedAt = priorCapturedAt, - source = source, - nameIsUnique = changedNameCounts[member.name.lowercase(Locale.ROOT)] == 1 && - rosterNameUidCounts[member.name.lowercase(Locale.ROOT)] == 1, - ) - } - changes.left.forEach { member -> - val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( - db = db, - uid = member.uid, - boundary = MembershipBoundary.WITHDRAW, - from = priorCapturedAt, - observedAt = snapshot.capturedAt, - ) - val membershipPeriodId = exactMembershipPeriodId ?: closeLatestMembershipPeriod( - db = db, - uid = member.uid, - leftAt = snapshot.capturedAt, - precision = EvidencePrecision.INFERRED, - source = source, - ) - markInactive(db, member.uid, snapshot.capturedAt) - if (exactMembershipPeriodId == null) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - member.uid, - member.name, - MemberEventType.LEFT, - snapshot.capturedAt, - source, - ) - correlateMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - member = SnapshotMember(member.uid, member.name, 0, 0, 0, 0, 0, 0), - type = MemberEventType.LEFT, - boundary = MembershipBoundary.WITHDRAW, - observedAt = snapshot.capturedAt, - from = priorCapturedAt, - nameIsUnique = - changedNameCounts[member.name.lowercase(Locale.ROOT)] == 1 && - rosterNameUidCounts[ - member.name.lowercase(Locale.ROOT) - ] == 1, - ) - } - } - changes.renamed.forEach { rename -> - insertEvent( - db = db, - uid = rename.uid, - type = MemberEventType.RENAMED, - occurredAt = snapshot.capturedAt, - observedAt = snapshot.capturedAt, - precision = EvidencePrecision.INFERRED, - source = source, - note = "${rename.oldName} -> ${rename.newName}", - ) - } - } - // A roster is authoritative for current presence. Repair any missing open - // membershipPeriod left by an incomplete historical Updates feed. - snapshot.members.forEach { member -> - ensureRosterActiveMembershipPeriod(db, member.uid, source) - } - - trimPlatoonActivity(db) - resolveUnresolvedActivityUids(db) - db.setTransactionSuccessful() - return SnapshotIngestResult( - snapshotId = snapshotId, - duplicate = false, - initialRoster = changes.initialRoster, - joined = changes.joined.size, - rejoined = changes.rejoined.size, - left = changes.left.size, - renamed = changes.renamed.size, - ) - } finally { - db.endTransaction() - } - } + ): SnapshotIngestResult = SnapshotIngestion.ingest( + writableDatabase, + snapshot, + source, + historicalOnly, + deferHistoricalMembershipReconciliation, + ) - // Function Name: reconcileSnapshotMembershipHistory - // Description: - // - Replays every retained roster in chronological order after late historical imports. - // - Rebuilds only weak snapshot-derived membership periods and preserves manual or exact evidence. - // - Creates inactive member records for people found only in historical rosters. - // Parameters: - // - None. - // Returns: - // - Unit after atomically reconciling member identities and membership periods. + /** Replays every retained roster after late historical imports; see [RosterHistoryReplay]. */ @Synchronized fun reconcileSnapshotMembershipHistory() { val db = writableDatabase - db.beginTransaction() - try { - reconcileSnapshotMembershipHistory(db) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - private fun reconcileSnapshotMembershipHistory(db: SQLiteDatabase) { - val snapshots = readRosterFrames(db) - if (snapshots.isEmpty()) return - - ensureHistoricalMembers(db, snapshots) - removeWeakSnapshotMembershipPeriods(db) - - val allUids = snapshots.flatMap { frame -> frame.members.keys }.toSortedSet() - allUids.forEach { uid -> - val runs = buildRosterPresenceRuns(snapshots, uid) - runs.forEach { run -> - if (!reconcilePreservedMembershipPeriodOverlap(db, uid, run)) { - insertRosterPresenceRun(db, uid, run) - } - } - } - resolveUnresolvedActivityUids(db) + db.inTransaction { RosterHistoryReplay.reconcile(db) } } - private fun readRosterFrames(db: SQLiteDatabase): List { - val frames = linkedMapOf>() - db.rawQuery( - """ - SELECT s.id, s.captured_at, sm.uid, sm.name, sm.level - FROM snapshots s - JOIN snapshot_members sm ON sm.snapshot_id = s.id - ORDER BY s.captured_at, s.id, sm.uid - """.trimIndent(), - null, - ).use { cursor -> - while (cursor.moveToNext()) { - val snapshotId = cursor.getLong(0) - val capturedAt = Instant.ofEpochMilli(cursor.getLong(1)) - frames.getOrPut(snapshotId) { linkedMapOf() }[cursor.getLong(2)] = - RosterIdentity(cursor.getString(3), cursor.getInt(4), capturedAt) - } - } - return frames.map { (_, members) -> - RosterFrame(members.values.first().capturedAt, members) - } - } - - private fun ensureHistoricalMembers(db: SQLiteDatabase, snapshots: List) { - val latestFrame = snapshots.last() - snapshots.flatMap { frame -> - frame.members.map { (uid, identity) -> Triple(uid, identity, frame.capturedAt) } - }.groupBy { it.first }.forEach { (uid, observations) -> - val firstSeenAt = observations.minOf { it.third } - val latest = observations.maxBy { it.third } - if (!memberExists(db, uid)) { - db.insertOrThrow( - "members", - null, - ContentValues().apply { - put("uid", uid) - put("current_name", latest.second.name) - put("current_level", latest.second.level) - put("is_active", if (uid in latestFrame.members) 1 else 0) - put("first_seen_at", firstSeenAt.toEpochMilli()) - put("last_seen_at", latest.third.toEpochMilli()) - put("note", "") - }, - ) - } else { - db.execSQL( - "UPDATE members SET first_seen_at = MIN(first_seen_at, ?) WHERE uid = ?", - arrayOf(firstSeenAt.toEpochMilli(), uid), - ) - } - } - } - - private fun removeWeakSnapshotMembershipPeriods(db: SQLiteDatabase) { - val sourceNames = SNAPSHOT_EVENT_SOURCES.map(EvidenceSource::name) - val weakIds = db.rawQuery( - """ - SELECT id - FROM membership_periods - WHERE joined_source IN (?, ?) - AND (left_source IS NULL OR left_source IN (?, ?)) - AND joined_precision IN (?, ?) - AND (left_precision IS NULL OR left_precision = ?) - AND note = '' - """.trimIndent(), - arrayOf( - sourceNames[0], - sourceNames[1], - sourceNames[0], - sourceNames[1], - EvidencePrecision.UNKNOWN.name, - EvidencePrecision.INFERRED.name, - EvidencePrecision.INFERRED.name, - ), - ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } - - weakIds.forEach { membershipPeriodId -> - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN (SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "is_automatic = 0 AND event_id IN (SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "platoon_activity", - ContentValues().apply { putNull("member_event_id") }, - "member_event_id IN (SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.delete( - "member_events", - "membership_period_id = ?", - arrayOf(membershipPeriodId.toString()), - ) - db.delete("membership_periods", "id = ?", arrayOf(membershipPeriodId.toString())) - } - } - - private fun buildRosterPresenceRuns( - snapshots: List, - uid: Long, - ): List { - val runs = mutableListOf() - var index = 0 - while (index < snapshots.size) { - if (uid !in snapshots[index].members) { - index += 1 - continue - } - val firstIndex = index - while (index + 1 < snapshots.size && uid in snapshots[index + 1].members) index += 1 - val lastIndex = index - val first = snapshots[firstIndex] - val last = snapshots[lastIndex] - runs += RosterPresenceRun( - firstSeenAt = first.capturedAt, - lastSeenAt = last.capturedAt, - joinedAt = first.capturedAt.takeIf { firstIndex > 0 }, - leftAt = snapshots.getOrNull(lastIndex + 1)?.capturedAt, - name = first.members.getValue(uid).name, - ) - index += 1 - } - return runs - } - - // Function Name: reconcilePreservedMembershipPeriodOverlap - // Description: - // - Preserves exact, manual, or user-annotated membership periods during roster replay. - // - Closes every overlapping open preserved period at an observed roster absence. - // - Allows a later roster-presence run to become a separate inferred rejoin period. - // Parameters: - // - db: Writable database participating in the replay transaction. - // - uid: Stable member identity whose roster run is being reconciled. - // - run: One contiguous roster-presence run bounded by observed absence when known. - // Returns: - // - True when a preserved period covers this run; false when a new weak run is required. - private fun reconcilePreservedMembershipPeriodOverlap( - db: SQLiteDatabase, - uid: Long, - run: RosterPresenceRun, - ): Boolean { - val overlaps = db.rawQuery( - """ - SELECT id, joined_at, joined_precision, joined_source, - left_at, left_precision, left_source - FROM membership_periods - WHERE uid = ? - AND (joined_at IS NULL OR joined_at <= ?) - AND (left_at IS NULL OR left_at > ?) - ORDER BY COALESCE(joined_at, -9223372036854775808), id - """.trimIndent(), - arrayOf( - uid.toString(), - run.lastSeenAt.toEpochMilli().toString(), - run.firstSeenAt.toEpochMilli().toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - PreservedMembershipPeriodOverlap( - id = cursor.getLong(0), - joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), - joinedPrecision = EvidencePrecision.valueOf(cursor.getString(2)), - joinedSource = EvidenceSource.valueOf(cursor.getString(3)), - leftAt = cursor.getNullableLong(4)?.let(Instant::ofEpochMilli), - leftPrecision = cursor.getNullableString(5) - ?.let(EvidencePrecision::valueOf), - leftSource = cursor.getNullableString(6) - ?.let(EvidenceSource::valueOf), - ), - ) - } - } - } - overlaps.forEach { membershipPeriod -> - val joinedAt = membershipPeriod.joinedAt - if ( - joinedAt != null && - run.firstSeenAt.isBefore(joinedAt) && - isWeakSnapshotBoundary( - membershipPeriod.joinedPrecision, - membershipPeriod.joinedSource, - ) - ) { - val updated = db.update( - "membership_periods", - ContentValues().apply { - put("joined_at", run.firstSeenAt.toEpochMilli()) - putNull("joined_date") - put("joined_time_known", 1) - put("joined_precision", EvidencePrecision.INFERRED.name) - put("joined_source", EvidenceSource.LEGACY_IMPORT.name) - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - if (updated == 1) { - updateSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriod.id, - uid = uid, - memberName = run.name, - type = if (hasEarlierMembershipPeriod(db, uid, membershipPeriod.id)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - observedAt = run.firstSeenAt, - ) - } - } - - val observedLeftAt = run.leftAt - val weakLeftBoundary = membershipPeriod.leftPrecision != null && - membershipPeriod.leftSource != null && - isWeakSnapshotBoundary( - membershipPeriod.leftPrecision, - membershipPeriod.leftSource, - ) - if ( - observedLeftAt != null && - ( - membershipPeriod.leftAt == null || - (weakLeftBoundary && membershipPeriod.leftAt != observedLeftAt) - ) - ) { - val updated = db.update( - "membership_periods", - ContentValues().apply { - put("left_at", observedLeftAt.toEpochMilli()) - putNull("left_date") - put("left_time_known", 1) - put("left_precision", EvidencePrecision.INFERRED.name) - put("left_source", EvidenceSource.LEGACY_IMPORT.name) - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - if (updated == 1) { - updateSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriod.id, - uid = uid, - memberName = run.name, - type = MemberEventType.LEFT, - observedAt = observedLeftAt, - ) - } - } else if ( - observedLeftAt == null && - membershipPeriod.leftAt != null && - weakLeftBoundary && - !run.lastSeenAt.isBefore(membershipPeriod.leftAt) - ) { - db.update( - "membership_periods", - ContentValues().apply { - putNull("left_at") - putNull("left_date") - putNull("left_time_known") - putNull("left_precision") - putNull("left_source") - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - deleteSnapshotBoundaryEvents( - db = db, - membershipPeriodId = membershipPeriod.id, - type = MemberEventType.LEFT, - ) - } - } - return overlaps.isNotEmpty() - } - - private fun isWeakSnapshotBoundary( - precision: EvidencePrecision, - source: EvidenceSource, - ): Boolean = precision in setOf(EvidencePrecision.UNKNOWN, EvidencePrecision.INFERRED) && - source in SNAPSHOT_EVENT_SOURCES - - private fun updateSnapshotBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - observedAt: Instant, - ) { - val compatibleTypes = when (type) { - MemberEventType.JOINED, - MemberEventType.REJOINED, - -> arrayOf(MemberEventType.JOINED.name, MemberEventType.REJOINED.name) - else -> arrayOf(type.name, type.name) - } - val updated = db.update( - "member_events", - ContentValues().apply { - put("event_type", type.name) - putNull("occurred_at") - putNull("event_date") - put("time_known", 1) - put("observed_at", observedAt.toEpochMilli()) - put("precision", EvidencePrecision.INFERRED.name) - put("source", EvidenceSource.LEGACY_IMPORT.name) - put("note", memberName) - }, - "membership_period_id = ? AND event_type IN (?, ?) " + - "AND source IN (?, ?)", - arrayOf( - membershipPeriodId.toString(), - compatibleTypes[0], - compatibleTypes[1], - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - ), - ) - if (updated == 0) { - insertSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = uid, - memberName = memberName, - type = type, - observedAt = observedAt, - source = EvidenceSource.LEGACY_IMPORT, - ) - } - } - - private fun deleteSnapshotBoundaryEvents( - db: SQLiteDatabase, - membershipPeriodId: Long, - type: MemberEventType, - ) { - val eventSelection = - "membership_period_id = ? AND event_type = ? AND source IN (?, ?)" - val eventArguments = arrayOf( - membershipPeriodId.toString(), - type.name, - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - ) - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE $eventSelection)", - eventArguments, - ) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "event_id IN (SELECT id FROM member_events WHERE $eventSelection)", - eventArguments, - ) - db.update( - "platoon_activity", - ContentValues().apply { putNull("member_event_id") }, - "member_event_id IN (SELECT id FROM member_events WHERE $eventSelection)", - eventArguments, - ) - db.delete("member_events", eventSelection, eventArguments) - } - - private fun insertRosterPresenceRun( - db: SQLiteDatabase, - uid: Long, - run: RosterPresenceRun, - ) { - val membershipPeriodId = db.insertOrThrow( - "membership_periods", - null, - ContentValues().apply { - put("uid", uid) - putNullableLong("joined_at", run.joinedAt?.toEpochMilli()) - putNullableLong("left_at", run.leftAt?.toEpochMilli()) - put("joined_time_known", 1) - putNullableInt("left_time_known", run.leftAt?.let { 1 }) - put( - "joined_precision", - if (run.joinedAt == null) EvidencePrecision.UNKNOWN.name else EvidencePrecision.INFERRED.name, - ) - put( - "left_precision", - run.leftAt?.let { EvidencePrecision.INFERRED.name }, - ) - put("joined_source", EvidenceSource.LEGACY_IMPORT.name) - put("left_source", run.leftAt?.let { EvidenceSource.LEGACY_IMPORT.name }) - }, - ) - if (run.joinedAt != null) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - run.name, - if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - run.joinedAt, - EvidenceSource.LEGACY_IMPORT, - ) - } - if (run.leftAt != null) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - run.name, - MemberEventType.LEFT, - run.leftAt, - EvidenceSource.LEGACY_IMPORT, - ) - } - } + @Synchronized + fun latestSnapshotIdentity(): Pair? = + PlatoonReadQueries.latestSnapshotIdentity(readableDatabase) @Synchronized - fun latestSnapshotIdentity(): Pair? = readableDatabase.rawQuery( - "SELECT captured_at, source_file FROM snapshots " + - "ORDER BY captured_at DESC, source_file DESC, id DESC LIMIT 1", - null, - ).use { cursor -> - if (cursor.moveToFirst()) { - Instant.ofEpochMilli(cursor.getLong(0)) to cursor.getNullableString(1) - } else { - null - } - } + fun firstSnapshotCapturedAfter(instant: Instant): Instant? = + PlatoonReadQueries.firstSnapshotCapturedAfter(readableDatabase, instant) @Synchronized - fun snapshotSourceFiles(): Set = readableDatabase.rawQuery( - "SELECT source_file FROM snapshots WHERE source_file IS NOT NULL", - null, - ).use { cursor -> - buildSet { - while (cursor.moveToNext()) add(cursor.getString(0)) - } - } + fun eventInstantsObservedAt(observedAt: Instant): List = + PlatoonReadQueries.eventInstantsObservedAt(readableDatabase, observedAt) - private fun insertSnapshotRows(db: SQLiteDatabase, snapshot: PlatoonSnapshot): Long { - val snapshotId = db.insertOrThrow( - "snapshots", - null, - ContentValues().apply { - put("captured_at", snapshot.capturedAt.toEpochMilli()) - put("source_file", snapshot.sourceFile) - put("game_version", snapshot.gameVersion) - }, - ) - snapshot.members.forEach { member -> insertSnapshotMember(db, snapshotId, member) } - return snapshotId - } + @Synchronized + fun snapshotSourceFiles(): Set = PlatoonReadQueries.snapshotSourceFiles(readableDatabase) @Synchronized fun ingestPlatoonActivity( observations: List, capturedAt: Instant, - ): ActivityIngestResult { - val acceptedObservations = PlatoonObservationPolicy.activity(observations) - if (acceptedObservations.isEmpty()) return ActivityIngestResult(0, 0, 0) - val db = writableDatabase - db.beginTransaction() - try { - var inserted = 0 - var resolved = 0 - acceptedObservations.forEach { observation -> - val resolvedUid = resolveUidForName( - db, - observation.memberName, - observation.occurredAt, - ) - val result = db.insertWithOnConflict( - "platoon_activity", - null, - ContentValues().apply { - put("occurred_at", observation.occurredAt.toEpochMilli()) - put("action_id", observation.actionId) - put("kind", observation.kind) - put("member_name", observation.memberName) - put("captured_at", capturedAt.toEpochMilli()) - putNullableLong("resolved_uid", resolvedUid) - put( - "resolution", - if (resolvedUid == null) { - ActivityResolution.UNRESOLVED.name - } else { - ActivityResolution.UNIQUE_ROSTER_NAME.name - }, - ) - }, - SQLiteDatabase.CONFLICT_IGNORE, - ) - if (result != -1L) { - inserted += 1 - if (resolvedUid != null) resolved += 1 - } - } - trimPlatoonActivity(db) - resolveUnresolvedActivityUids(db) - reconcileInferredMembershipBoundaries(db) - db.setTransactionSuccessful() - return ActivityIngestResult(acceptedObservations.size, inserted, resolved) - } finally { - db.endTransaction() - } - } + ): ActivityIngestResult = ActivityIngestion.ingest(writableDatabase, observations, capturedAt) @Synchronized fun ingestPlatoonUpdates( observations: List, capturedAt: Instant, - ): UpdatesIngestResult { - val acceptedObservations = PlatoonObservationPolicy.updates(observations) - if (acceptedObservations.isEmpty()) return UpdatesIngestResult(0, 0, 0) - val db = writableDatabase - db.beginTransaction() - try { - var membershipEvents = 0 - var patrolFacts = 0 - acceptedObservations.forEach { observation -> - observation.members.forEach { - ensureUpdateMember(db, it, observation.occurredAt) - } - val affectedMembers = PlatoonUpdateSemantics.affectedMembers( - kind = observation.kind, - members = observation.members, - ) - when (PlatoonUpdateSemantics.effect(observation.kind)) { - PlatoonUpdateEffect.JOIN -> affectedMembers.forEach { member -> - if (applyExactUpdateBoundary( - db = db, - member = member, - type = MemberEventType.JOINED, - boundary = MembershipBoundary.JOIN, - occurredAt = observation.occurredAt, - observedAt = capturedAt, - ) - ) { - membershipEvents += 1 - } - synchronizeCurrentMembershipFromUpdate( - db, - member.uid, - ) - } - PlatoonUpdateEffect.WITHDRAW -> affectedMembers.forEach { member -> - if (applyExactUpdateBoundary( - db = db, - member = member, - type = MemberEventType.LEFT, - boundary = MembershipBoundary.WITHDRAW, - occurredAt = observation.occurredAt, - observedAt = capturedAt, - ) - ) { - membershipEvents += 1 - } - synchronizeCurrentMembershipFromUpdate( - db, - member.uid, - ) - } - PlatoonUpdateEffect.REMOVED -> affectedMembers.forEach { member -> - if (applyExactUpdateBoundary( - db = db, - member = member, - type = MemberEventType.REMOVED, - boundary = MembershipBoundary.WITHDRAW, - occurredAt = observation.occurredAt, - observedAt = capturedAt, - ) - ) { - membershipEvents += 1 - } - synchronizeCurrentMembershipFromUpdate( - db, - member.uid, - ) - } - PlatoonUpdateEffect.DAILY_PATROL -> affectedMembers.forEach { member -> - val inserted = db.insertWithOnConflict( - "platoon_activity", - null, - ContentValues().apply { - put("occurred_at", observation.occurredAt.toEpochMilli()) - put("action_id", DAILY_PATROL_REWARD_ACTION_ID) - put("kind", observation.kind) - put("member_name", member.name) - put("captured_at", capturedAt.toEpochMilli()) - put("resolved_uid", member.uid) - put("resolution", ActivityResolution.EXACT_UPDATE.name) - }, - SQLiteDatabase.CONFLICT_IGNORE, - ) - if (inserted != -1L) patrolFacts += 1 - } - PlatoonUpdateEffect.IGNORE -> Unit - } - } - trimPlatoonActivity(db) - db.setTransactionSuccessful() - return UpdatesIngestResult(acceptedObservations.size, membershipEvents, patrolFacts) - } finally { - db.endTransaction() - } - } - - private fun synchronizeCurrentMembershipFromUpdate( - db: SQLiteDatabase, - uid: Long, - ) { - val latestSnapshotAt = latestSnapshotCapturedAt(db) - val snapshotFilter = if (latestSnapshotAt == null) "" else "AND occurred_at > ?" - val arguments = mutableListOf( - uid.toString(), - EvidenceSource.GAME_UPDATES.name, - EvidencePrecision.EXACT.name, - MemberEventType.JOINED.name, - MemberEventType.REJOINED.name, - MemberEventType.LEFT.name, - MemberEventType.REMOVED.name, - ).apply { - latestSnapshotAt?.let { add(it.toEpochMilli().toString()) } - } - val latestBoundary = db.rawQuery( - """ - SELECT event_type, note, occurred_at - FROM member_events - WHERE uid = ? - AND source = ? - AND precision = ? - AND event_type IN (?, ?, ?, ?) - AND occurred_at IS NOT NULL - $snapshotFilter - ORDER BY occurred_at DESC, - CASE event_type - WHEN '${MemberEventType.REMOVED.name}' THEN 0 - WHEN '${MemberEventType.LEFT.name}' THEN 1 - WHEN '${MemberEventType.REJOINED.name}' THEN 2 - ELSE 3 - END, - id DESC - LIMIT 1 - """.trimIndent(), - arguments.toTypedArray(), - ).use { cursor -> - if (!cursor.moveToFirst()) return - LatestMembershipBoundary( - type = MemberEventType.valueOf(cursor.getString(0)), - name = cursor.getString(1), - occurredAt = Instant.ofEpochMilli(cursor.getLong(2)), - ) - } - db.update( - "members", - ContentValues().apply { - put("current_name", latestBoundary.name) - put( - "is_active", - if (latestBoundary.type in setOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - ) { - 1 - } else { - 0 - }, - ) - put("last_seen_at", latestBoundary.occurredAt.toEpochMilli()) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - } - - private data class LatestMembershipBoundary( - val type: MemberEventType, - val name: String, - val occurredAt: Instant, - ) - - private fun ensureUpdateMember( - db: SQLiteDatabase, - member: PlatoonUpdateMemberObservation, - observedAt: Instant, - ) { - if (memberExists(db, member.uid)) return - db.insertOrThrow( - "members", - null, - ContentValues().apply { - put("uid", member.uid) - put("current_name", member.name) - put("current_level", 0) - put("is_active", 0) - put("first_seen_at", observedAt.toEpochMilli()) - put("last_seen_at", observedAt.toEpochMilli()) - put("note", "") - }, - ) - } - - private fun recordSnapshotJoin( - db: SQLiteDatabase, - member: SnapshotMember, - type: MemberEventType, - observedAt: Instant, - priorCapturedAt: Instant?, - source: EvidenceSource, - nameIsUnique: Boolean, - ) { - val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( - db = db, - uid = member.uid, - boundary = MembershipBoundary.JOIN, - from = priorCapturedAt, - observedAt = observedAt, - ) - if (exactMembershipPeriodId != null) return - - val membershipPeriodId = insertMembershipPeriod( - db = db, - uid = member.uid, - joinedAt = observedAt, - precision = EvidencePrecision.INFERRED, - source = source, - ) - insertSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = member.uid, - memberName = member.name, - type = type, - observedAt = observedAt, - source = source, - ) - correlateMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - member = member, - type = type, - boundary = MembershipBoundary.JOIN, - observedAt = observedAt, - from = priorCapturedAt, - nameIsUnique = nameIsUnique, - ) - } - - private fun findRosterConfirmedExactMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - from: Instant?, - observedAt: Instant, - ): Long? { - val boundaryColumn = if (boundary == MembershipBoundary.JOIN) "joined_at" else "left_at" - val sourceColumn = if (boundary == MembershipBoundary.JOIN) { - "joined_source" - } else { - "left_source" - } - val precisionColumn = if (boundary == MembershipBoundary.JOIN) { - "joined_precision" - } else { - "left_precision" - } - val compatibility = if (boundary == MembershipBoundary.JOIN) { - "(left_at IS NULL OR left_at >= ?)" - } else { - "(joined_at IS NULL OR joined_at <= ?)" - } - val fromClause = if (from == null) "" else "AND $boundaryColumn > ?" - val arguments = buildList { - add(uid.toString()) - add(EvidenceSource.GAME_UPDATES.name) - add(EvidencePrecision.EXACT.name) - add(observedAt.toEpochMilli().toString()) - if (from != null) add(from.toEpochMilli().toString()) - add(observedAt.toEpochMilli().toString()) - }.toTypedArray() - val candidates = db.rawQuery( - """ - SELECT id, $boundaryColumn - FROM membership_periods - WHERE uid = ? - AND $sourceColumn = ? - AND $precisionColumn = ? - AND $boundaryColumn <= ? - $fromClause - AND $compatibility - ORDER BY $boundaryColumn DESC, id DESC - """.trimIndent(), - arguments, - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add(cursor.getLong(0) to Instant.ofEpochMilli(cursor.getLong(1))) - } - } - } - return candidates.firstOrNull { (_, boundaryAt) -> - !hasLaterOppositeBoundaryThrough( - db = db, - uid = uid, - boundary = boundary, - boundaryAt = boundaryAt, - observedAt = observedAt, - ) - }?.first - } - - private fun applyExactUpdateBoundary( - db: SQLiteDatabase, - member: PlatoonUpdateMemberObservation, - type: MemberEventType, - boundary: MembershipBoundary, - occurredAt: Instant, - observedAt: Instant, - ): Boolean { - val boundaryTypes = when (boundary) { - MembershipBoundary.JOIN -> listOf(MemberEventType.JOINED, MemberEventType.REJOINED) - MembershipBoundary.WITHDRAW -> listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - } - val exactExists = db.rawQuery( - """ - SELECT 1 - FROM member_events - WHERE uid = ? - AND source = ? - AND occurred_at = ? - AND event_type IN (?, ?) - LIMIT 1 - """.trimIndent(), - arrayOf( - member.uid.toString(), - EvidenceSource.GAME_UPDATES.name, - occurredAt.toEpochMilli().toString(), - boundaryTypes[0].name, - boundaryTypes[1].name, - ), - ).use(Cursor::moveToFirst) - if (exactExists) return false - - val candidates = db.rawQuery( - """ - SELECT id, membership_period_id, COALESCE(occurred_at, observed_at), - ABS(COALESCE(occurred_at, observed_at) - ?) AS distance - FROM member_events - WHERE uid = ? - AND event_type IN (?, ?) - AND source != ? - AND ABS(COALESCE(occurred_at, observed_at) - ?) <= ? - ORDER BY distance, - CASE source - WHEN '${EvidenceSource.GAME_UPDATES.name}' THEN 0 - WHEN '${EvidenceSource.MANUAL.name}' THEN 1 - ELSE 2 - END, - id DESC - """.trimIndent(), - arrayOf( - occurredAt.toEpochMilli().toString(), - member.uid.toString(), - boundaryTypes[0].name, - boundaryTypes[1].name, - EvidenceSource.GAME_UPDATES.name, - occurredAt.toEpochMilli().toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - BoundaryEventCandidate( - id = cursor.getLong(0), - membershipPeriodId = cursor.getNullableLong(1), - boundaryAt = Instant.ofEpochMilli(cursor.getLong(2)), - ), - ) - } - } - } - val correlatableCandidates = candidates.filter { candidate -> - !hasOppositeBoundaryBetween( - db = db, - uid = member.uid, - boundary = boundary, - first = candidate.boundaryAt, - second = occurredAt, - ) - } - val membershipPeriodId = correlatableCandidates - .firstNotNullOfOrNull(BoundaryEventCandidate::membershipPeriodId) - ?: findOrCreateUpdateMembershipPeriod(db, member.uid, boundary, occurredAt) - - correlatableCandidates.forEach { candidate -> - db.delete("member_events", "id = ?", arrayOf(candidate.id.toString())) - } - correlatableCandidates.mapNotNull(BoundaryEventCandidate::membershipPeriodId) - .filter { it != membershipPeriodId } - .distinct() - .forEach { candidateMembershipPeriodId -> - mergeShadowInferredMembershipPeriod( - db = db, - selectedMembershipPeriodId = membershipPeriodId, - shadowMembershipPeriodId = candidateMembershipPeriodId, - boundary = boundary, - occurredAt = occurredAt, - ) - } - - applyExactMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - uid = member.uid, - memberName = member.name, - type = if (boundary == MembershipBoundary.JOIN && - hasEarlierMembershipPeriod(db, member.uid, membershipPeriodId) - ) { - MemberEventType.REJOINED - } else { - type - }, - boundary = boundary, - occurredAt = occurredAt, - observedAt = observedAt, - activityIds = emptyList(), - ) - if (boundary == MembershipBoundary.WITHDRAW && - MembershipChronology.shouldRestoreRosterActiveMembershipPeriod( - memberIsActive = isActiveMember(db, member.uid), - withdrawalPredatesRoster = hasRosterPresenceAfter(db, member.uid, occurredAt), - ) - ) { - /* - * The historical boundary and a later authoritative roster are - * both true. Close the compatible historical membershipPeriod above, then - * preserve exactly one unknown current membershipPeriod for the later roster. - * A subsequent exact rejoin in the replay will refine this membershipPeriod. - */ - ensureRosterActiveMembershipPeriod(db, member.uid, EvidenceSource.SNAPSHOT) - } - return true - } - - private fun hasOppositeBoundaryBetween( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - first: Instant, - second: Instant, - ): Boolean { - if (first == second) return false - val oppositeTypes = when (boundary) { - MembershipBoundary.JOIN -> listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - MembershipBoundary.WITHDRAW -> listOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - } - val start = minOf(first, second).toEpochMilli() - val end = maxOf(first, second).toEpochMilli() - return db.rawQuery( - """ - SELECT 1 - FROM member_events - WHERE uid = ? - AND event_type IN (?, ?) - AND COALESCE(occurred_at, observed_at) > ? - AND COALESCE(occurred_at, observed_at) < ? - LIMIT 1 - """.trimIndent(), - arrayOf( - uid.toString(), - oppositeTypes[0].name, - oppositeTypes[1].name, - start.toString(), - end.toString(), - ), - ).use(Cursor::moveToFirst) - } - - private fun hasLaterOppositeBoundaryThrough( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - boundaryAt: Instant, - observedAt: Instant, - ): Boolean { - if (!observedAt.isAfter(boundaryAt)) return false - val oppositeTypes = when (boundary) { - MembershipBoundary.JOIN -> listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - MembershipBoundary.WITHDRAW -> listOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - } - val oppositeBoundaryAt = db.rawQuery( - """ - SELECT MIN(COALESCE(occurred_at, observed_at)) - FROM member_events - WHERE uid = ? - AND event_type IN (?, ?) - AND COALESCE(occurred_at, observed_at) > ? - AND COALESCE(occurred_at, observed_at) <= ? - """.trimIndent(), - arrayOf( - uid.toString(), - oppositeTypes[0].name, - oppositeTypes[1].name, - boundaryAt.toEpochMilli().toString(), - observedAt.toEpochMilli().toString(), - ), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getNullableLong(0)?.let(Instant::ofEpochMilli) else null - } - return MembershipChronology.hasSupersedingOppositeBoundary( - boundaryAt = boundaryAt, - observedAt = observedAt, - oppositeBoundaryTimes = listOfNotNull(oppositeBoundaryAt), - ) - } - - private fun findOrCreateUpdateMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - occurredAt: Instant, - ): Long { - val occurredAtMillis = occurredAt.toEpochMilli() - val nearby = when (boundary) { - MembershipBoundary.JOIN -> db.rawQuery( - """ - SELECT id - FROM membership_periods - WHERE uid = ? - AND (left_at IS NULL OR left_at >= ?) - AND ( - joined_at IS NULL - OR ( - joined_precision != ? - AND ABS(joined_at - ?) <= ? - ) - ) - ORDER BY - CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, - CASE WHEN joined_at IS NULL THEN 0 ELSE 1 END, - ABS(COALESCE(joined_at, ?) - ?), - id DESC - LIMIT 1 - """.trimIndent(), - arrayOf( - uid.toString(), - occurredAtMillis.toString(), - EvidencePrecision.EXACT.name, - occurredAtMillis.toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - occurredAtMillis.toString(), - occurredAtMillis.toString(), - ), - ) - MembershipBoundary.WITHDRAW -> db.rawQuery( - """ - SELECT id - FROM membership_periods - WHERE uid = ? - AND (joined_at IS NULL OR joined_at <= ?) - AND ( - left_at IS NULL - OR ( - left_precision != ? - AND ABS(left_at - ?) <= ? - ) - ) - ORDER BY - CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, - CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, - joined_at DESC, - ABS(COALESCE(left_at, ?) - ?), - id DESC - LIMIT 1 - """.trimIndent(), - arrayOf( - uid.toString(), - occurredAtMillis.toString(), - EvidencePrecision.EXACT.name, - occurredAtMillis.toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - occurredAtMillis.toString(), - occurredAtMillis.toString(), - ), - ) - }.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - if (nearby != null) return nearby - return insertMembershipPeriod( - db = db, - uid = uid, - joinedAt = if (boundary == MembershipBoundary.JOIN) occurredAt else null, - precision = if (boundary == MembershipBoundary.JOIN) { - EvidencePrecision.EXACT - } else { - EvidencePrecision.UNKNOWN - }, - source = EvidenceSource.GAME_UPDATES, - ) - } - - private fun isActiveMember(db: SQLiteDatabase, uid: Long): Boolean = - db.rawQuery( - "SELECT is_active FROM members WHERE uid = ? LIMIT 1", - arrayOf(uid.toString()), - ).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) != 0 } - - private fun hasRosterPresenceAfter( - db: SQLiteDatabase, - uid: Long, - occurredAt: Instant, - ): Boolean { - val latestRosterPresenceAt = db.rawQuery( - """ - SELECT MAX(snapshot.captured_at) - FROM snapshots snapshot - JOIN snapshot_members member ON member.snapshot_id = snapshot.id - WHERE member.uid = ? - """.trimIndent(), - arrayOf(uid.toString()), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getNullableLong(0)?.let(Instant::ofEpochMilli) else null - } - return MembershipChronology.withdrawalPredatesRosterPresence( - withdrewAt = occurredAt, - latestRosterPresenceAt = latestRosterPresenceAt, - ) - } - - private fun mergeShadowInferredMembershipPeriod( - db: SQLiteDatabase, - selectedMembershipPeriodId: Long, - shadowMembershipPeriodId: Long, - boundary: MembershipBoundary, - occurredAt: Instant, - ) { - val boundaryColumn = if (boundary == MembershipBoundary.JOIN) "joined_at" else "left_at" - val precisionColumn = if (boundary == MembershipBoundary.JOIN) { - "joined_precision" - } else { - "left_precision" - } - val shadow = db.rawQuery( - """ - SELECT joined_at, left_at, - joined_date, left_date, - joined_time_known, left_time_known, - joined_precision, left_precision, - joined_source, left_source - FROM membership_periods - WHERE id = ? - AND $precisionColumn IN (?, ?) - AND ( - $boundaryColumn IS NULL - OR ABS($boundaryColumn - ?) <= ? - ) - LIMIT 1 - """.trimIndent(), - arrayOf( - shadowMembershipPeriodId.toString(), - EvidencePrecision.INFERRED.name, - EvidencePrecision.UNKNOWN.name, - occurredAt.toEpochMilli().toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - if (!cursor.moveToFirst()) { - null - } else { - ShadowMembershipPeriod( - joinedAt = cursor.getNullableLong(0), - leftAt = cursor.getNullableLong(1), - joinedDate = cursor.getNullableLong(2), - leftDate = cursor.getNullableLong(3), - joinedTimeKnown = cursor.getNullableInt(4), - leftTimeKnown = cursor.getNullableInt(5), - joinedPrecision = cursor.getNullableString(6), - leftPrecision = cursor.getNullableString(7), - joinedSource = cursor.getNullableString(8), - leftSource = cursor.getNullableString(9), - ) - } - } ?: return - - val oppositePrefix = if (boundary == MembershipBoundary.JOIN) "left" else "joined" - val oppositeAt = if (boundary == MembershipBoundary.JOIN) shadow.leftAt else shadow.joinedAt - if (oppositeAt != null) { - val selectedHasOpposite = db.rawQuery( - "SELECT ${oppositePrefix}_at FROM membership_periods WHERE id = ?", - arrayOf(selectedMembershipPeriodId.toString()), - ).use { cursor -> cursor.moveToFirst() && !cursor.isNull(0) } - if (selectedHasOpposite) return - - val values = ContentValues().apply { - put("${oppositePrefix}_at", oppositeAt) - if (boundary == MembershipBoundary.JOIN) { - putNullableLong("${oppositePrefix}_date", shadow.leftDate) - putNullableInt("${oppositePrefix}_time_known", shadow.leftTimeKnown) - put("${oppositePrefix}_precision", shadow.leftPrecision) - put("${oppositePrefix}_source", shadow.leftSource) - } else { - putNullableLong("${oppositePrefix}_date", shadow.joinedDate) - putNullableInt("${oppositePrefix}_time_known", shadow.joinedTimeKnown) - put("${oppositePrefix}_precision", shadow.joinedPrecision) - put("${oppositePrefix}_source", shadow.joinedSource) - } - } - db.update( - "membership_periods", - values, - "id = ?", - arrayOf(selectedMembershipPeriodId.toString()), - ) - val oppositeTypes = if (boundary == MembershipBoundary.JOIN) { - listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - } else { - listOf(MemberEventType.JOINED, MemberEventType.REJOINED) - } - db.update( - "member_events", - ContentValues().apply { put("membership_period_id", selectedMembershipPeriodId) }, - "membership_period_id = ? AND event_type IN (?, ?)", - arrayOf( - shadowMembershipPeriodId.toString(), - oppositeTypes[0].name, - oppositeTypes[1].name, - ), - ) - } - db.delete("member_events", "membership_period_id = ?", arrayOf(shadowMembershipPeriodId.toString())) - db.delete("membership_periods", "id = ?", arrayOf(shadowMembershipPeriodId.toString())) - } + ): UpdatesIngestResult = ExactBoundaryCorrelation.ingest(writableDatabase, observations, capturedAt) @Synchronized - fun listDailyPatrolFacts(from: Instant, until: Instant): List { - require(!until.isBefore(from)) - return readableDatabase.query( - true, - "platoon_activity", - arrayOf("resolved_uid", "occurred_at"), - """ - action_id = ? AND resolved_uid IS NOT NULL - AND resolution = ? AND occurred_at >= ? AND occurred_at < ? - """.trimIndent(), - arrayOf( - DAILY_PATROL_REWARD_ACTION_ID.toString(), - ActivityResolution.EXACT_UPDATE.name, - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - ), - null, - null, - "occurred_at, resolved_uid", - null, - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - DailyPatrolFact( - uid = cursor.getLong(0), - occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), - ), - ) - } - } - } - } + fun listDailyPatrolFacts(from: Instant, until: Instant): List = + PlatoonReadQueries.listDailyPatrolFacts(readableDatabase, from, until) @Synchronized fun addWithdrawnMember( @@ -1890,37 +127,7 @@ internal class PlatoonDatabase( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue, note: String, - ): Boolean { - require(uid > 0) - require(name.isNotBlank()) - require(isValidMembershipRange(joined, withdrew)) - val db = writableDatabase - db.beginTransaction() - try { - if (memberExists(db, uid)) return false - db.insertOrThrow( - "members", - null, - ContentValues().apply { - put("uid", uid) - put("current_name", name.trim()) - put("custom_name", name.trim()) - put("current_level", 0) - put("is_active", 0) - put("first_seen_at", joined.instant.toEpochMilli()) - put("last_seen_at", withdrew.instant.toEpochMilli()) - put("note", "") - }, - ) - val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) - replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, name.trim(), joined, withdrew) - synchronizeAndVerifyMembershipState(db, uid) - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + ): Boolean = MembershipEditing.addWithdrawnMember(writableDatabase, ::gameZone, uid, name, joined, withdrew, note) @Synchronized fun addMembershipPeriod( @@ -1928,22 +135,7 @@ internal class PlatoonDatabase( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, note: String, - ): Boolean { - require(isValidMembershipRange(joined, withdrew)) - val db = writableDatabase - db.beginTransaction() - try { - val memberName = memberName(db, uid) ?: return false - if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) return false - val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) - replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) - synchronizeAndVerifyMembershipState(db, uid) - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + ): Boolean = MembershipEditing.addMembershipPeriod(writableDatabase, ::gameZone, uid, joined, withdrew, note) @Synchronized fun listSnapshots(limit: Int = 100): List { @@ -1952,9 +144,8 @@ internal class PlatoonDatabase( } /** - * Loads only the snapshots capable of affecting a reporting period plus - * the nearest prior roster/baseline. This avoids an arbitrary historical - * cap and keeps weekly rendering independent of database growth. + * Loads only the snapshots capable of affecting a reporting period plus the nearest prior + * roster/baseline, independent of database growth. */ @Synchronized fun listSnapshotsForPeriod(from: Instant, until: Instant): List { @@ -1963,37 +154,8 @@ internal class PlatoonDatabase( } @Synchronized - fun listMemberStatuses(activeOnly: Boolean = false): List { - val db = readableDatabase - val selection = if (activeOnly) "is_active = 1" else null - return db.query( - "members", - MEMBER_COLUMNS, - selection, - null, - null, - null, - "is_active DESC, COALESCE(custom_name, current_name) COLLATE NOCASE, uid", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - val uid = cursor.getLong(0) - add( - MemberStatus( - uid = uid, - name = cursor.getString(1), - level = cursor.getLong(2), - isActive = cursor.getInt(3) != 0, - firstSeenAt = Instant.ofEpochMilli(cursor.getLong(4)), - lastSeenAt = Instant.ofEpochMilli(cursor.getLong(5)), - note = cursor.getString(6), - membershipPeriods = readMembershipPeriods(db, uid), - ), - ) - } - } - } - } + fun listMemberStatuses(activeOnly: Boolean = false): List = + PlatoonReadQueries.listMemberStatuses(readableDatabase, activeOnly) @Synchronized fun listEvents( @@ -2001,153 +163,18 @@ internal class PlatoonDatabase( until: Instant, fromDate: LocalDate, untilDate: LocalDate, - ): List { - require(!until.isBefore(from)) - require(!untilDate.isBefore(fromDate)) - return readableDatabase.query( - "member_events", - EVENT_COLUMNS, - """ - ( - event_date IS NOT NULL - AND event_date >= CAST(? AS INTEGER) - AND event_date < CAST(? AS INTEGER) - ) - OR - ( - event_date IS NULL - AND COALESCE(occurred_at, observed_at) >= CAST(? AS INTEGER) - AND COALESCE(occurred_at, observed_at) < CAST(? AS INTEGER) - ) - """.trimIndent(), - arrayOf( - fromDate.toEpochDay().toString(), - untilDate.toEpochDay().toString(), - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - ), - null, - null, - "COALESCE(occurred_at, observed_at), id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.toMemberEvent()) - } - } - } + ): List = PlatoonReadQueries.listEvents(readableDatabase, from, until, fromDate, untilDate) @Synchronized - fun updateMember(uid: Long, name: String, note: String): Boolean { - require(name.isNotBlank()) - val values = ContentValues().apply { - put("custom_name", name.trim()) - put("note", note.trim()) - } - return writableDatabase.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 - } + fun updateMember(uid: Long, name: String, note: String): Boolean = + MembershipEditing.updateMember(writableDatabase, uid, name, note) @Synchronized - fun deleteMember(uid: Long): Boolean { - require(uid > 0) - val db = writableDatabase - db.beginTransaction() - try { - if (!memberExists(db, uid)) return false - val uidArgument = arrayOf(uid.toString()) - db.delete("weekly_overrides", "uid = ?", uidArgument) - db.delete( - "platoon_activity", - "resolved_uid = ? OR member_event_id IN " + - "(SELECT id FROM member_events WHERE uid = ?)", - arrayOf(uid.toString(), uid.toString()), - ) - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE uid = ?)", - uidArgument, - ) - db.delete("member_events", "uid = ?", uidArgument) - db.delete("membership_periods", "uid = ?", uidArgument) - db.delete("snapshot_members", "uid = ?", uidArgument) - val deleted = db.delete("members", "uid = ?", uidArgument) == 1 - check(deleted) { "Member disappeared during deletion" } - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + fun deleteMember(uid: Long): Boolean = MembershipEditing.deleteMember(writableDatabase, uid) - // Function Name: deleteMembershipPeriod - // Description: - // - Deletes one membership period and its derived boundary events atomically. - // - Refuses to remove the member's final period and recomputes current active state. - // Parameters: - // - membershipPeriodId: Persistent period identifier selected in member details. - // Returns: - // - True when the period was deleted; false when it was missing or was the only period. @Synchronized - fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean { - require(membershipPeriodId > 0) - val db = writableDatabase - db.beginTransaction() - try { - val uid = db.rawQuery( - "SELECT uid FROM membership_periods WHERE id = ?", - arrayOf(membershipPeriodId.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - ?: return false - val periodCount = db.rawQuery( - "SELECT COUNT(*) FROM membership_periods WHERE uid = ?", - arrayOf(uid.toString()), - ).use { cursor -> - check(cursor.moveToFirst()) - cursor.getLong(0) - } - if (periodCount <= 1L) return false - - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "event_id IN " + - "(SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "platoon_activity", - ContentValues().apply { putNull("member_event_id") }, - "member_event_id IN " + - "(SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.delete( - "member_events", - "membership_period_id = ?", - arrayOf(membershipPeriodId.toString()), - ) - if ( - db.delete( - "membership_periods", - "id = ? AND uid = ?", - arrayOf(membershipPeriodId.toString(), uid.toString()), - ) != 1 - ) { - return false - } - synchronizeAndVerifyMembershipState(db, uid) - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean = + MembershipEditing.deleteMembershipPeriod(writableDatabase, ::gameZone, membershipPeriodId) @Synchronized fun updateMembershipPeriod( @@ -2155,384 +182,44 @@ internal class PlatoonDatabase( joined: MembershipBoundaryValue, left: MembershipBoundaryValue?, note: String, - ): Boolean { - val db = writableDatabase - db.beginTransaction() - try { - val membershipPeriod = readMembershipPeriodForUpdate(db, membershipPeriodId) ?: return false - val effectiveJoined = if (membershipPeriod.joinedSource.isImmutableMembershipBoundary()) { - membershipPeriod.joined - } else { - joined - } ?: return false - val effectiveLeft = if (membershipPeriod.leftSource?.isImmutableMembershipBoundary() == true) { - membershipPeriod.left - } else { - left - } - require(isValidMembershipRange(effectiveJoined, effectiveLeft)) - if ( - !canStoreMembershipPeriod( - db, - membershipPeriod.uid, - membershipPeriodId, - effectiveJoined, - effectiveLeft, - ) - ) { - return false - } - val joinedChanged = editableMembershipBoundaryChanged( - original = membershipPeriod.joined, - requested = joined, - source = membershipPeriod.joinedSource, - ) - val leftChanged = editableMembershipBoundaryChanged( - original = membershipPeriod.left, - requested = left, - source = membershipPeriod.leftSource, - ) - val resultingJoinedSource = if (joinedChanged) { - EvidenceSource.MANUAL - } else { - membershipPeriod.joinedSource - } - val resultingLeftSource = if (leftChanged) { - left?.let { EvidenceSource.MANUAL } - } else { - membershipPeriod.leftSource - } - val updated = db.update( - "membership_periods", - ContentValues().apply { - if (joinedChanged) { - putMembershipBoundary("joined", joined) - put("joined_precision", EvidencePrecision.MANUAL.name) - put("joined_source", EvidenceSource.MANUAL.name) - } - if (leftChanged) { - if (left == null) { - clearMembershipBoundary("left") - putNull("left_precision") - putNull("left_source") - } else { - putMembershipBoundary("left", left) - put("left_precision", EvidencePrecision.MANUAL.name) - put("left_source", EvidenceSource.MANUAL.name) - } - } - put("note", note.trim()) - }, - "id = ?", - arrayOf(membershipPeriodId.toString()), - ) == 1 - if (updated && (joinedChanged || leftChanged)) { - replaceManualMembershipPeriodEvents( - db, - membershipPeriodId, - membershipPeriod.uid, - memberName(db, membershipPeriod.uid).orEmpty(), - effectiveJoined.takeIf { - resultingJoinedSource == EvidenceSource.MANUAL - }, - effectiveLeft.takeIf { - resultingLeftSource == EvidenceSource.MANUAL - }, - ) - } - if (updated) synchronizeAndVerifyMembershipState(db, membershipPeriod.uid) - db.setTransactionSuccessful() - return updated - } finally { - db.endTransaction() - } - } - - // Function Name: canStoreMembershipPeriod - // Description: - // - Evaluates a proposed manual period together with every persisted period for the member. - // - Rejects overlapping ranges and multiple open periods before any database row is changed. - private fun canStoreMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - replacedPeriodId: Long?, - joined: MembershipBoundaryValue, - left: MembershipBoundaryValue?, - ): Boolean { - val proposed = MembershipInterval( - id = replacedPeriodId ?: Long.MAX_VALUE, - joinedAt = joined.instant, - leftAt = left?.instant, - joinedDate = joined.date, - leftDate = left?.date, - joinedTimeKnown = joined.timeKnown, - leftTimeKnown = left?.timeKnown ?: true, - ) - val periods = readMembershipIntervals(db, uid) - .filterNot { it.id == replacedPeriodId } + proposed - return MembershipConsistencyPolicy.violation(periods) == null - } - - // Function Name: synchronizeAndVerifyMembershipState - // Description: - // - Derives current member activity from the canonical period timeline. - // - Verifies chronology and event ownership before allowing the surrounding transaction to commit. - private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, uid: Long) { - val periods = readMembershipIntervals(db, uid) - checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } - val violation = MembershipConsistencyPolicy.violation(periods) - check(violation == null) { violation.orEmpty() } - val updated = db.update( - "members", - ContentValues().apply { - put("is_active", if (MembershipConsistencyPolicy.isActive(periods)) 1 else 0) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - check(updated == 1) { "Membership state has no owning member" } - val mismatchedEvent = db.rawQuery( - "SELECT 1 FROM member_events event " + - "JOIN membership_periods period ON period.id = event.membership_period_id " + - "WHERE event.uid != period.uid AND (event.uid = ? OR period.uid = ?) LIMIT 1", - arrayOf(uid.toString(), uid.toString()), - ).use(Cursor::moveToFirst) - check(!mismatchedEvent) { "Membership event belongs to a different member" } - } - - // Function Name: synchronizeAllMembershipStates - // Description: - // - Repairs the derived active flag while upgrading databases created before schema v13. - // - Leaves every evidence period intact and derives only the current-state projection. - private fun synchronizeAllMembershipStates(db: SQLiteDatabase) { - db.execSQL( - """ - UPDATE members - SET is_active = CASE WHEN EXISTS ( - SELECT 1 - FROM membership_periods period - WHERE period.uid = members.uid AND period.left_at IS NULL - ) THEN 1 ELSE 0 END - """.trimIndent(), - ) - } - - private fun readMembershipIntervals(db: SQLiteDatabase, uid: Long): List = - db.rawQuery( - "SELECT id, joined_at, left_at, joined_date, left_date, " + - "joined_time_known, left_time_known " + - "FROM membership_periods WHERE uid = ? ORDER BY id", - arrayOf(uid.toString()), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - MembershipInterval( - id = cursor.getLong(0), - joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), - leftAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli), - joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), - leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), - joinedTimeKnown = cursor.getNullableInt(5)?.let { it != 0 } ?: true, - leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 } ?: true, - ), - ) - } - } - } + ): Boolean = MembershipEditing.updateMembershipPeriod( + writableDatabase, + ::gameZone, + membershipPeriodId, + joined, + left, + note, + ) @Synchronized - fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long { - require(text.isNotBlank()) - val manualNoteCount = readableDatabase.rawQuery( - "SELECT COUNT(*) FROM weekly_notes WHERE period_start = ? AND is_automatic = 0", - arrayOf(periodStartEpochDay.toString()), - ).use { cursor -> - check(cursor.moveToFirst()) - cursor.getInt(0) - } - if (!WeeklyNotePolicy.canAdd(manualNoteCount)) throw WeeklyNoteLimitException() - return writableDatabase.insertOrThrow( - "weekly_notes", - null, - ContentValues().apply { - put("period_start", periodStartEpochDay) - put("game_day", gameDayEpochDay) - put("text", text.trim()) - putNull("event_id") - put("is_automatic", 0) - }, - ) - } + fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long = + WeeklyAnnotationStore.addWeeklyNote(writableDatabase, periodStartEpochDay, gameDayEpochDay, text) @Synchronized fun listWeeklyNotes(periodStartEpochDay: Long): List = - readableDatabase.query( - "weekly_notes", - WEEKLY_NOTE_COLUMNS, - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - null, - null, - "game_day, id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - WeeklyNote( - id = cursor.getLong(0), - periodStart = java.time.LocalDate.ofEpochDay(cursor.getLong(1)), - gameDay = java.time.LocalDate.ofEpochDay(cursor.getLong(2)), - text = cursor.getString(3), - eventId = cursor.getNullableLong(4), - isAutomatic = cursor.getInt(5) != 0, - ), - ) - } - } - } + WeeklyAnnotationStore.listWeeklyNotes(readableDatabase, periodStartEpochDay) @Synchronized - fun weeklyNotePeriodStart(id: Long): LocalDate? = readableDatabase.rawQuery( - "SELECT period_start FROM weekly_notes WHERE id = ? AND is_automatic = 0", - arrayOf(id.toString()), - ).use { cursor -> - if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null - } + fun weeklyNotePeriodStart(id: Long): LocalDate? = + WeeklyAnnotationStore.weeklyNotePeriodStart(readableDatabase, id) @Synchronized - fun deleteWeeklyNote(id: Long): Boolean = - writableDatabase.delete( - "weekly_notes", - "id = ? AND is_automatic = 0", - arrayOf(id.toString()), - ) == 1 + fun deleteWeeklyNote(id: Long): Boolean = WeeklyAnnotationStore.deleteWeeklyNote(writableDatabase, id) @Synchronized fun listWeeklyOverrides(periodStartEpochDay: Long): List = - readableDatabase.query( - "weekly_overrides", - WEEKLY_OVERRIDE_COLUMNS, - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - null, - null, - "game_day, uid", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - WeeklyCellOverride( - uid = cursor.getLong(0), - periodStart = java.time.LocalDate.ofEpochDay(cursor.getLong(1)), - gameDay = java.time.LocalDate.ofEpochDay(cursor.getLong(2)), - meritDelta = cursor.getNullableLong(3), - scoreDelta = cursor.getNullableLong(4), - attempts = cursor.getNullableInt(5), - attended = cursor.getNullableBoolean(6), - dailyPatrol = cursor.getNullableBoolean(7), - ), - ) - } - } - } + WeeklyAnnotationStore.listWeeklyOverrides(readableDatabase, periodStartEpochDay) @Synchronized - fun listWeeklyEvidenceDays(zoneId: ZoneId): List { - val db = readableDatabase - val days = mutableListOf() - listOf( - "snapshots" to "captured_at", - "platoon_activity" to "occurred_at", - ).forEach { (table, expression) -> - db.rawQuery("SELECT DISTINCT $expression FROM $table", null).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) { - days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) - } - } - } - } - db.rawQuery( - "SELECT DISTINCT COALESCE(occurred_at, observed_at) " + - "FROM member_events WHERE event_date IS NULL", - null, - ).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) { - days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) - } - } - } - listOf("weekly_notes", "weekly_overrides").forEach { table -> - db.rawQuery("SELECT DISTINCT period_start FROM $table", null).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) - } - } - } - db.rawQuery("SELECT DISTINCT event_date FROM member_events", null).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) - } - } - return days.distinct() - } + fun listWeeklyEvidenceDays(zoneId: ZoneId): List = + PlatoonReadQueries.listWeeklyEvidenceDays(readableDatabase, zoneId) @Synchronized fun replaceWeeklyOverrides( periodStartEpochDay: Long, overrides: List, - ) { - require(overrides.all { it.periodStart.toEpochDay() == periodStartEpochDay }) - require( - overrides.all { - it.attempts == null || it.attempts in 0..ActivityInference.MAX_DAILY_ATTEMPTS - }, - ) - require(overrides.none { it.attended == false && it.dailyPatrol == true }) - val db = writableDatabase - db.beginTransaction() - try { - db.delete( - "weekly_overrides", - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - ) - overrides.forEach { override -> - db.insertOrThrow( - "weekly_overrides", - null, - ContentValues().apply { - put("uid", override.uid) - put("period_start", periodStartEpochDay) - put("game_day", override.gameDay.toEpochDay()) - putNullableLong("merit_delta", override.meritDelta) - putNullableLong("score_delta", override.scoreDelta) - putNullableInt("attempts", override.attempts) - putNullableBoolean("attended", override.attended) - putNullableBoolean("daily_patrol", override.dailyPatrol) - }, - ) - } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } + ) = WeeklyAnnotationStore.replaceWeeklyOverrides(writableDatabase, periodStartEpochDay, overrides) - // Function Name: recordWeeklyReportHistory - // Description: - // - Appends one changed immutable weekly projection and retains at most fifteen revisions. - // - Clears an older restored projection only when new evidence changes the live table. - // Parameters: - // - periodStartEpochDay: Sunday key of the projected week. - // - recordedAt: Time at which the changed projection became visible. - // - fingerprint: SHA-256 identity of the bounded serialized projection. - // - payload: Versioned serialized weekly projection. - // - clearActiveOnChange: Whether a changed live projection should leave restore mode. - // Returns: - // - ID of the existing or newly recorded revision. @Synchronized fun recordWeeklyReportHistory( periodStartEpochDay: Long, @@ -2540,1483 +227,44 @@ internal class PlatoonDatabase( fingerprint: String, payload: ByteArray, clearActiveOnChange: Boolean, - ): Long { - require(fingerprint.matches(Regex("[0-9a-f]{64}"))) - require(payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) - val db = writableDatabase - db.beginTransaction() - try { - val latest = db.rawQuery( - "SELECT id, fingerprint FROM weekly_report_history " + - "WHERE period_start = ? ORDER BY recorded_at DESC, id DESC LIMIT 1", - arrayOf(periodStartEpochDay.toString()), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getLong(0) to cursor.getString(1) else null - } - if (latest?.second == fingerprint) { - db.setTransactionSuccessful() - return latest.first - } - val id = db.insertOrThrow( - "weekly_report_history", - null, - ContentValues().apply { - put("period_start", periodStartEpochDay) - put("recorded_at", recordedAt.toEpochMilli()) - put("fingerprint", fingerprint) - put("report_blob", payload) - }, - ) - if (clearActiveOnChange) { - db.delete( - "weekly_report_history_state", - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - ) - } - db.delete( - "weekly_report_history", - "period_start = ? AND id NOT IN (" + - "SELECT id FROM weekly_report_history WHERE period_start = ? " + - "ORDER BY recorded_at DESC, id DESC LIMIT ?)", - arrayOf( - periodStartEpochDay.toString(), - periodStartEpochDay.toString(), - MAX_WEEKLY_REPORT_HISTORY.toString(), - ), - ) - db.setTransactionSuccessful() - return id - } finally { - db.endTransaction() - } - } + ): Long = WeeklyHistoryStore.record( + writableDatabase, + periodStartEpochDay, + recordedAt, + fingerprint, + payload, + clearActiveOnChange, + ) @Synchronized fun listWeeklyReportHistory(periodStartEpochDay: Long): List = - readableDatabase.rawQuery( - "SELECT history.id, history.recorded_at, " + - "CASE WHEN state.active_history_id = history.id THEN 1 ELSE 0 END " + - "FROM weekly_report_history history " + - "LEFT JOIN weekly_report_history_state state " + - "ON state.period_start = history.period_start " + - "WHERE history.period_start = ? " + - "ORDER BY history.recorded_at DESC, history.id DESC LIMIT ?", - arrayOf(periodStartEpochDay.toString(), MAX_WEEKLY_REPORT_HISTORY.toString()), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - WeeklyReportHistoryEntry( - id = cursor.getLong(0), - periodStart = LocalDate.ofEpochDay(periodStartEpochDay), - recordedAt = Instant.ofEpochMilli(cursor.getLong(1)), - active = cursor.getInt(2) != 0, - ), - ) - } - } - } + WeeklyHistoryStore.list(readableDatabase, periodStartEpochDay) @Synchronized fun weeklyReportHistoryPayload(id: Long, periodStartEpochDay: Long): ByteArray? = - readableDatabase.rawQuery( - "SELECT report_blob FROM weekly_report_history WHERE id = ? AND period_start = ?", - arrayOf(id.toString(), periodStartEpochDay.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + WeeklyHistoryStore.payload(readableDatabase, id, periodStartEpochDay) @Synchronized fun activeWeeklyReportHistoryPayload(periodStartEpochDay: Long): ByteArray? = - readableDatabase.rawQuery( - "SELECT history.report_blob FROM weekly_report_history_state state " + - "JOIN weekly_report_history history ON history.id = state.active_history_id " + - "WHERE state.period_start = ? AND history.period_start = state.period_start", - arrayOf(periodStartEpochDay.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + WeeklyHistoryStore.activePayload(readableDatabase, periodStartEpochDay) @Synchronized - fun activateWeeklyReportHistory(id: Long, periodStartEpochDay: Long): Boolean { - val db = writableDatabase - val exists = db.rawQuery( - "SELECT 1 FROM weekly_report_history WHERE id = ? AND period_start = ?", - arrayOf(id.toString(), periodStartEpochDay.toString()), - ).use(Cursor::moveToFirst) - if (!exists) return false - db.insertWithOnConflict( - "weekly_report_history_state", - null, - ContentValues().apply { - put("period_start", periodStartEpochDay) - put("active_history_id", id) - }, - SQLiteDatabase.CONFLICT_REPLACE, - ) - return true - } + fun activateWeeklyReportHistory(id: Long, periodStartEpochDay: Long): Boolean = + WeeklyHistoryStore.activate(writableDatabase, id, periodStartEpochDay) @Synchronized fun clearActiveWeeklyReportHistory(periodStartEpochDay: Long): Boolean = - writableDatabase.delete( - "weekly_report_history_state", - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - ) > 0 - - @Synchronized - fun clearWeeklyReportHistory() { - val db = writableDatabase - db.beginTransaction() - try { - db.delete("weekly_report_history_state", null, null) - db.delete("weekly_report_history", null, null) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } + WeeklyHistoryStore.clearActive(writableDatabase, periodStartEpochDay) - // Function Name: replaceWeeklyReportHistory - // Description: - // - Replaces every derived weekly revision in one SQLite transaction. - // - Validates the complete replacement before deleting existing history. - // Parameters: - // - replacements: One prepared current revision for each evidence-backed week. - // Returns: - // - Unit after the complete replacement commits, or after SQLite rolls back on failure. @Synchronized - internal fun replaceWeeklyReportHistory(replacements: List) { - require(replacements.map { it.periodStartEpochDay }.distinct().size == replacements.size) - replacements.forEach { replacement -> - require(replacement.fingerprint.matches(Regex("[0-9a-f]{64}"))) - require(replacement.payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) - } - val db = writableDatabase - db.beginTransaction() - try { - db.delete("weekly_report_history_state", null, null) - db.delete("weekly_report_history", null, null) - replacements.forEach { replacement -> - db.insertOrThrow( - "weekly_report_history", - null, - ContentValues().apply { - put("period_start", replacement.periodStartEpochDay) - put("recorded_at", replacement.recordedAt.toEpochMilli()) - put("fingerprint", replacement.fingerprint) - put("report_blob", replacement.payload) - }, - ) - } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - private fun createWeeklyOverridesTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS weekly_overrides ( - uid INTEGER NOT NULL, - period_start INTEGER NOT NULL, - game_day INTEGER NOT NULL, - merit_delta INTEGER, - score_delta INTEGER, - attempts INTEGER, - attended INTEGER, - daily_patrol INTEGER, - PRIMARY KEY(uid, game_day) - ) - """.trimIndent(), - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS weekly_overrides_period " + - "ON weekly_overrides(period_start, game_day)", - ) - } - - private fun createPlatoonActivityTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS platoon_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - occurred_at INTEGER NOT NULL, - action_id INTEGER NOT NULL, - kind INTEGER NOT NULL, - member_name TEXT NOT NULL, - captured_at INTEGER NOT NULL, - resolved_uid INTEGER REFERENCES members(uid), - resolution TEXT NOT NULL DEFAULT 'UNRESOLVED', - member_event_id INTEGER REFERENCES member_events(id) - ) - """.trimIndent(), - ) - createPlatoonActivityIndexes(db) - } - - private fun createPlatoonActivityIndexes(db: SQLiteDatabase) { - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_member_time " + - "ON platoon_activity(member_name, occurred_at)", - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_action_time " + - "ON platoon_activity(action_id, occurred_at)", - ) - db.execSQL( - """ - CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_exact_identity - ON platoon_activity(occurred_at, action_id, kind, resolved_uid) - WHERE resolved_uid IS NOT NULL - """.trimIndent(), - ) - db.execSQL( - """ - CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_unresolved_identity - ON platoon_activity(occurred_at, action_id, kind, member_name) - WHERE resolved_uid IS NULL - """.trimIndent(), - ) - createPlatoonActivityRetentionIndex(db) - } - - private fun createPlatoonActivityRetentionIndex(db: SQLiteDatabase) { - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_resolution_retention " + - "ON platoon_activity(resolved_uid, captured_at DESC, id DESC)", - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_retention_order " + - "ON platoon_activity(captured_at DESC, id DESC)", - ) - } - - private fun createPlatoonMaintenanceStateTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS platoon_maintenance_state ( - key TEXT PRIMARY KEY, - value INTEGER NOT NULL - ) - """.trimIndent(), - ) - } - - private fun latestSnapshotCapturedAt(db: SQLiteDatabase): Instant? = - db.rawQuery("SELECT MAX(captured_at) FROM snapshots", null).use { cursor -> - if (cursor.moveToFirst() && !cursor.isNull(0)) { - Instant.ofEpochMilli(cursor.getLong(0)) - } else { - null - } - } - - private fun resolveUidForName( - db: SQLiteDatabase, - memberName: String, - occurredAt: Instant, - ): Long? { - val windowStart = occurredAt.minusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() - val windowEnd = occurredAt.plusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() - val historical = db.rawQuery( - """ - SELECT DISTINCT sm.uid - FROM snapshot_members sm - JOIN snapshots s ON s.id = sm.snapshot_id - WHERE sm.name = ? COLLATE BINARY - AND s.captured_at >= ? - AND s.captured_at <= ? - LIMIT 2 - """.trimIndent(), - arrayOf(memberName, windowStart.toString(), windowEnd.toString()), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.getLong(0)) - } - } - if (historical.size == 1) return historical.single() - if (historical.size > 1) return null - - val current = db.rawQuery( - "SELECT uid FROM members WHERE current_name = ? COLLATE BINARY LIMIT 2", - arrayOf(memberName), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.getLong(0)) - } - } - return current.singleOrNull() - } - - private fun resolveUnresolvedActivityUids(db: SQLiteDatabase) { - createPlatoonMaintenanceStateTable(db) - val cursorId = db.rawQuery( - "SELECT value FROM platoon_maintenance_state WHERE key = ?", - arrayOf(ACTIVITY_RESOLUTION_CURSOR_KEY), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getLong(0) else 0L - } - val unresolved = readUnresolvedActivityBatch( - db = db, - selection = "resolved_uid IS NULL AND id > ?", - selectionArgs = arrayOf(cursorId.toString()), - limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS, - ).toMutableList() - if (unresolved.size < MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS) { - unresolved += readUnresolvedActivityBatch( - db = db, - selection = "resolved_uid IS NULL AND id <= ?", - selectionArgs = arrayOf(cursorId.toString()), - limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS - unresolved.size, - ) - } - unresolved.forEach { activity -> - val uid = resolveUidForName(db, activity.memberName, activity.occurredAt) - ?: return@forEach - val updated = db.updateWithOnConflict( - "platoon_activity", - ContentValues().apply { - put("resolved_uid", uid) - put("resolution", ActivityResolution.UNIQUE_ROSTER_NAME.name) - }, - "id = ? AND resolved_uid IS NULL", - arrayOf(activity.id.toString()), - SQLiteDatabase.CONFLICT_IGNORE, - ) - if (updated == 0) { - db.delete( - "platoon_activity", - """ - id = ? AND EXISTS ( - SELECT 1 - FROM platoon_activity existing - WHERE existing.id != ? - AND existing.occurred_at = ? - AND existing.action_id = ? - AND existing.kind = ? - AND existing.resolved_uid = ? - ) - """.trimIndent(), - arrayOf( - activity.id.toString(), - activity.id.toString(), - activity.occurredAt.toEpochMilli().toString(), - activity.actionId.toString(), - activity.kind.toString(), - uid.toString(), - ), - ) - } - } - unresolved.lastOrNull()?.let { activity -> - db.insertWithOnConflict( - "platoon_maintenance_state", - null, - ContentValues().apply { - put("key", ACTIVITY_RESOLUTION_CURSOR_KEY) - put("value", activity.id) - }, - SQLiteDatabase.CONFLICT_REPLACE, - ) - } - } - - private fun readUnresolvedActivityBatch( - db: SQLiteDatabase, - selection: String, - selectionArgs: Array, - limit: Int, - ): List { - if (limit <= 0) return emptyList() - return db.query( - "platoon_activity", - arrayOf("id", "occurred_at", "member_name", "action_id", "kind"), - selection, - selectionArgs, - null, - null, - "id", - limit.toString(), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - UnresolvedActivity( - id = cursor.getLong(0), - occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), - memberName = cursor.getString(2), - actionId = cursor.getLong(3), - kind = cursor.getLong(4), - ), - ) - } - } - } - } - - // Function Name: trimPlatoonActivity - // Description: - // - Caps cumulative network-derived activity evidence to a bounded newest-first history. - // - Runs on database open and after each ingest so an older oversized database self-heals. - // Parameters: - // - db: Writable database participating in the caller's transaction when applicable. - // Returns: - // - Unit after deleting only rows beyond the retained observation limit. - private fun trimPlatoonActivity(db: SQLiteDatabase) { - db.execSQL( - """ - DELETE FROM platoon_activity - WHERE id IN ( - SELECT id - FROM platoon_activity - ORDER BY captured_at DESC, id DESC - LIMIT -1 OFFSET $MAX_STORED_ACTIVITY_OBSERVATIONS - ) - """.trimIndent(), - ) - } - - private fun ensureRosterActiveMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - source: EvidenceSource, - ) { - val hasOpenMembershipPeriod = db.rawQuery( - "SELECT 1 FROM membership_periods WHERE uid = ? AND left_at IS NULL LIMIT 1", - arrayOf(uid.toString()), - ).use(Cursor::moveToFirst) - if (hasOpenMembershipPeriod) return - insertMembershipPeriod( - db = db, - uid = uid, - joinedAt = null, - precision = EvidencePrecision.UNKNOWN, - source = source, - ) - } - - private fun correlateMembershipBoundary( - db: SQLiteDatabase, - membershipPeriodId: Long, - member: SnapshotMember, - type: MemberEventType, - boundary: MembershipBoundary, - observedAt: Instant, - from: Instant?, - nameIsUnique: Boolean, - ) { - if (!nameIsUnique || from == null) return - val candidates = readMembershipActivityCandidates( - db = db, - memberName = member.name, - from = from, - until = observedAt, - ) - val occurredAt = candidates.map(ActivityCandidate::occurredAt).distinct().singleOrNull() - ?: return - applyExactMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - uid = member.uid, - memberName = member.name, - type = type, - boundary = boundary, - occurredAt = occurredAt, - observedAt = observedAt, - activityIds = candidates.map(ActivityCandidate::id), - ) - } - - private fun readMembershipActivityCandidates( - db: SQLiteDatabase, - memberName: String, - from: Instant, - until: Instant, - ): List = db.rawQuery( - """ - SELECT id, occurred_at - FROM platoon_activity - WHERE member_name = ? COLLATE BINARY - AND occurred_at > ? - AND occurred_at <= ? - AND action_id NOT IN (?, ?) - AND member_event_id IS NULL - ORDER BY occurred_at, id - """.trimIndent(), - arrayOf( - memberName, - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - DAILY_PATROL_REWARD_ACTION_ID.toString(), - DAILY_PATROL_RELATED_ACTION_ID.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add(ActivityCandidate(cursor.getLong(0), Instant.ofEpochMilli(cursor.getLong(1)))) - } - } - } - - private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { - val activities = db.rawQuery( - """ - SELECT id, occurred_at, member_name, resolved_uid - FROM platoon_activity - WHERE member_event_id IS NULL - AND resolved_uid IS NOT NULL - AND action_id NOT IN (?, ?) - ORDER BY occurred_at, id - """.trimIndent(), - arrayOf( - DAILY_PATROL_REWARD_ACTION_ID.toString(), - DAILY_PATROL_RELATED_ACTION_ID.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - ResolvedActivityCandidate( - id = cursor.getLong(0), - occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), - memberName = cursor.getString(2), - uid = cursor.getLong(3), - ), - ) - } - } - } - activities.forEach { activity -> - val boundaries = readNearbyInferredBoundaries(db, activity) - val boundary = boundaries.singleOrNull() ?: return@forEach - val eventType = when (boundary.boundary) { - MembershipBoundary.WITHDRAW -> MemberEventType.LEFT - MembershipBoundary.JOIN -> { - if (hasEarlierMembershipPeriod(db, boundary.uid, boundary.membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - } - } - } - applyExactMembershipBoundary( - db = db, - membershipPeriodId = boundary.membershipPeriodId, - uid = boundary.uid, - memberName = activity.memberName, - type = eventType, - boundary = boundary.boundary, - occurredAt = activity.occurredAt, - observedAt = boundary.inferredAt, - activityIds = listOf(activity.id), - ) - } - } - - private fun readNearbyInferredBoundaries( - db: SQLiteDatabase, - activity: ResolvedActivityCandidate, - ): List = db.rawQuery( - """ - SELECT id, uid, joined_at, left_at, joined_precision, left_precision - FROM membership_periods - WHERE uid = ? - AND ( - (joined_at IS NOT NULL AND joined_precision = ? AND ABS(joined_at - ?) <= ?) - OR - (left_at IS NOT NULL AND left_precision = ? AND ABS(left_at - ?) <= ?) - ) - """.trimIndent(), - arrayOf( - activity.uid.toString(), - EvidencePrecision.INFERRED.name, - activity.occurredAt.toEpochMilli().toString(), - MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), - EvidencePrecision.INFERRED.name, - activity.occurredAt.toEpochMilli().toString(), - MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - val membershipPeriodId = cursor.getLong(0) - val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2) - val leftAt = cursor.getNullableLong(3) - if (joinedAt != null && - cursor.getString(4) == EvidencePrecision.INFERRED.name && - kotlin.math.abs(joinedAt - activity.occurredAt.toEpochMilli()) <= - MEMBERSHIP_CORRELATION_WINDOW_MILLIS - ) { - add( - InferredBoundary( - membershipPeriodId, - uid, - MembershipBoundary.JOIN, - Instant.ofEpochMilli(joinedAt), - ), - ) - } - if (leftAt != null && - cursor.getNullableString(5) == EvidencePrecision.INFERRED.name && - kotlin.math.abs(leftAt - activity.occurredAt.toEpochMilli()) <= - MEMBERSHIP_CORRELATION_WINDOW_MILLIS - ) { - add( - InferredBoundary( - membershipPeriodId, - uid, - MembershipBoundary.WITHDRAW, - Instant.ofEpochMilli(leftAt), - ), - ) - } - } - } - } - - private fun applyExactMembershipBoundary( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - boundary: MembershipBoundary, - occurredAt: Instant, - observedAt: Instant, - activityIds: List, - ) { - val values = ContentValues().apply { - when (boundary) { - MembershipBoundary.JOIN -> { - put("joined_at", occurredAt.toEpochMilli()) - putNull("joined_date") - put("joined_time_known", 1) - put("joined_precision", EvidencePrecision.EXACT.name) - put("joined_source", EvidenceSource.GAME_UPDATES.name) - } - MembershipBoundary.WITHDRAW -> { - put("left_at", occurredAt.toEpochMilli()) - putNull("left_date") - put("left_time_known", 1) - put("left_precision", EvidencePrecision.EXACT.name) - put("left_source", EvidenceSource.GAME_UPDATES.name) - } - } - } - db.update("membership_periods", values, "id = ?", arrayOf(membershipPeriodId.toString())) - val inferredTypes = when (boundary) { - MembershipBoundary.JOIN -> arrayOf( - MemberEventType.JOINED.name, - MemberEventType.REJOINED.name, - ) - MembershipBoundary.WITHDRAW -> arrayOf( - MemberEventType.LEFT.name, - MemberEventType.REMOVED.name, - ) - } - db.delete( - "member_events", - "membership_period_id = ? AND source IN (?, ?) AND event_type IN (?, ?)", - arrayOf( - membershipPeriodId.toString(), - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - inferredTypes[0], - inferredTypes[1], - ), - ) - val eventId = insertEvent( - db = db, - uid = uid, - type = type, - occurredAt = occurredAt, - observedAt = observedAt, - precision = EvidencePrecision.EXACT, - source = EvidenceSource.GAME_UPDATES, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - activityIds.forEach { activityId -> - db.update( - "platoon_activity", - ContentValues().apply { - put("resolved_uid", uid) - put("resolution", ActivityResolution.MEMBERSHIP_CORRELATION.name) - put("member_event_id", eventId) - }, - "id = ? AND member_event_id IS NULL", - arrayOf(activityId.toString()), - ) - } - } - - private fun insertManualMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - joined: MembershipBoundaryValue, - withdrew: MembershipBoundaryValue?, - note: String, - ): Long = db.insertOrThrow( - "membership_periods", - null, - ContentValues().apply { - put("uid", uid) - putMembershipBoundary("joined", joined) - put("joined_precision", EvidencePrecision.MANUAL.name) - put("joined_source", EvidenceSource.MANUAL.name) - if (withdrew == null) { - clearMembershipBoundary("left") - putNull("left_precision") - putNull("left_source") - } else { - putMembershipBoundary("left", withdrew) - put("left_precision", EvidencePrecision.MANUAL.name) - put("left_source", EvidenceSource.MANUAL.name) - } - put("note", note.trim()) - }, - ) - - private fun replaceManualMembershipPeriodEvents( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - joined: MembershipBoundaryValue?, - withdrew: MembershipBoundaryValue?, - ) { - db.delete( - "member_events", - "membership_period_id = ? AND source = ?", - arrayOf(membershipPeriodId.toString(), EvidenceSource.MANUAL.name), - ) - val observedAt = Instant.now() - if (joined != null) { - insertEvent( - db = db, - uid = uid, - type = if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - occurredAt = joined.instant, - eventDate = joined.date, - timeKnown = joined.timeKnown, - observedAt = observedAt, - precision = EvidencePrecision.MANUAL, - source = EvidenceSource.MANUAL, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - if (withdrew != null) { - insertEvent( - db = db, - uid = uid, - type = MemberEventType.LEFT, - occurredAt = withdrew.instant, - eventDate = withdrew.date, - timeKnown = withdrew.timeKnown, - observedAt = observedAt, - precision = EvidencePrecision.MANUAL, - source = EvidenceSource.MANUAL, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - } - - private fun linkLegacyMembershipPeriodEvents(db: SQLiteDatabase) { - db.rawQuery( - """ - SELECT t.id, t.uid, t.joined_at, t.left_at, - t.joined_precision, t.left_precision, - t.joined_source, t.left_source, - COALESCE(m.custom_name, m.current_name) - FROM membership_periods t - JOIN members m ON m.uid = t.uid - ORDER BY t.uid, t.id - """.trimIndent(), - null, - ).use { cursor -> - while (cursor.moveToNext()) { - val membershipPeriodId = cursor.getLong(0) - val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2) - val leftAt = cursor.getNullableLong(3) - val joinedPrecision = EvidencePrecision.valueOf(cursor.getString(4)) - val leftPrecision = cursor.getNullableString(5)?.let(EvidencePrecision::valueOf) - val joinedSource = EvidenceSource.valueOf(cursor.getString(6)) - val leftSource = cursor.getNullableString(7)?.let(EvidenceSource::valueOf) - val memberName = cursor.getString(8) - if (joinedAt != null) { - val expectedType = if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - } - val linked = linkLegacyBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = uid, - occurredAt = joinedAt, - precision = joinedPrecision, - source = joinedSource, - expectedType = expectedType, - ) - if (!linked && joinedSource == EvidenceSource.MANUAL) { - insertLegacyManualBoundaryEvent( - db, - membershipPeriodId, - uid, - memberName, - expectedType, - joinedAt, - joinedPrecision, - ) - } - } - if (leftAt != null && leftPrecision != null && leftSource != null) { - val linked = linkLegacyBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = uid, - occurredAt = leftAt, - precision = leftPrecision, - source = leftSource, - expectedType = MemberEventType.LEFT, - ) - if (!linked && leftSource == EvidenceSource.MANUAL) { - insertLegacyManualBoundaryEvent( - db, - membershipPeriodId, - uid, - memberName, - MemberEventType.LEFT, - leftAt, - leftPrecision, - ) - } - } - } - } - } - - private fun linkLegacyBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - occurredAt: Long, - precision: EvidencePrecision, - source: EvidenceSource, - expectedType: MemberEventType, - ): Boolean { - val candidates = db.rawQuery( - """ - SELECT id, event_type, occurred_at, observed_at, source - FROM member_events - WHERE membership_period_id IS NULL - AND uid = ? - AND source = ? - AND precision = ? - ORDER BY id - """.trimIndent(), - arrayOf( - uid.toString(), - source.name, - precision.name, - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - LegacyEventCandidate( - id = cursor.getLong(0), - type = MemberEventType.valueOf(cursor.getString(1)), - occurredAt = cursor.getNullableLong(2), - observedAt = cursor.getLong(3), - source = EvidenceSource.valueOf(cursor.getString(4)), - ), - ) - } - } - } - val eventId = LegacyEventMigrationPolicy.selectCandidate( - expectedType, - occurredAt, - candidates, - ) - ?: return false - db.update( - "member_events", - ContentValues().apply { put("membership_period_id", membershipPeriodId) }, - "id = ?", - arrayOf(eventId.toString()), - ) - return true - } - - private fun insertLegacyManualBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - occurredAt: Long, - precision: EvidencePrecision, - ) { - val boundary = Instant.ofEpochMilli(occurredAt) - insertEvent( - db = db, - uid = uid, - type = type, - occurredAt = boundary, - observedAt = boundary, - precision = precision, - source = EvidenceSource.MANUAL, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - - private fun backfillManualCalendarDates(db: SQLiteDatabase, zoneId: ZoneId) { - val membershipPeriods = db.rawQuery( - """ - SELECT id, joined_at, left_at, joined_source, left_source - FROM membership_periods - WHERE (joined_source = ? AND joined_at IS NOT NULL) - OR (left_source = ? AND left_at IS NOT NULL) - """.trimIndent(), - arrayOf(EvidenceSource.MANUAL.name, EvidenceSource.MANUAL.name), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - ManualBoundaryDateBackfill( - id = cursor.getLong(0), - joinedAtMillis = cursor.getNullableLong(1), - leftAtMillis = cursor.getNullableLong(2), - joinedIsManual = cursor.getString(3) == EvidenceSource.MANUAL.name, - leftIsManual = - cursor.getNullableString(4) == EvidenceSource.MANUAL.name, - ), - ) - } - } - } - membershipPeriods.forEach { membershipPeriod -> - db.update( - "membership_periods", - ContentValues().apply { - if (membershipPeriod.joinedIsManual && membershipPeriod.joinedAtMillis != null) { - put( - "joined_date", - migrationCalendarDate(membershipPeriod.joinedAtMillis, zoneId).toEpochDay(), - ) - put("joined_time_known", 1) - } - if (membershipPeriod.leftIsManual && membershipPeriod.leftAtMillis != null) { - put( - "left_date", - migrationCalendarDate(membershipPeriod.leftAtMillis, zoneId).toEpochDay(), - ) - put("left_time_known", 1) - } - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - } - - val events = db.rawQuery( - """ - SELECT id, occurred_at - FROM member_events - WHERE source = ? - AND occurred_at IS NOT NULL - """.trimIndent(), - arrayOf(EvidenceSource.MANUAL.name), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getLong(1)) - } - } - events.forEach { (id, occurredAtMillis) -> - db.update( - "member_events", - ContentValues().apply { - put( - "event_date", - migrationCalendarDate(occurredAtMillis, zoneId).toEpochDay(), - ) - put("time_known", 1) - }, - "id = ?", - arrayOf(id.toString()), - ) - } - } - - private fun backfillSnapshotMembershipPeriodEvents(db: SQLiteDatabase) { - db.rawQuery( - """ - SELECT t.id, t.uid, t.joined_at, t.left_at, t.joined_source, t.left_source, - COALESCE(m.custom_name, m.current_name) - FROM membership_periods t - JOIN members m ON m.uid = t.uid - WHERE t.joined_source IN (?, ?) - OR t.left_source IN (?, ?) - ORDER BY t.uid, t.id - """.trimIndent(), - arrayOf( - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - ), - ).use { cursor -> - while (cursor.moveToNext()) { - val membershipPeriodId = cursor.getLong(0) - val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli) - val leftAt = cursor.getNullableLong(3)?.let(Instant::ofEpochMilli) - val joinedSource = EvidenceSource.valueOf(cursor.getString(4)) - val leftSource = cursor.getNullableString(5)?.let(EvidenceSource::valueOf) - val name = cursor.getString(6) - if (joinedAt != null && joinedSource in SNAPSHOT_EVENT_SOURCES) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - name, - if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - joinedAt, - joinedSource, - ) - } - if (leftAt != null && leftSource in SNAPSHOT_EVENT_SOURCES) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - name, - MemberEventType.LEFT, - leftAt, - requireNotNull(leftSource), - ) - } - } - } - } - - private fun insertSnapshotBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - observedAt: Instant, - source: EvidenceSource, - ) { - val compatibleTypes = when (type) { - MemberEventType.JOINED, - MemberEventType.REJOINED, - -> arrayOf(MemberEventType.JOINED.name, MemberEventType.REJOINED.name) - else -> arrayOf(type.name, type.name) - } - val exists = db.rawQuery( - "SELECT 1 FROM member_events " + - "WHERE membership_period_id = ? AND event_type IN (?, ?) LIMIT 1", - arrayOf(membershipPeriodId.toString(), compatibleTypes[0], compatibleTypes[1]), - ).use { cursor -> cursor.moveToFirst() } - if (exists) return - insertEvent( - db = db, - uid = uid, - type = type, - occurredAt = null, - observedAt = observedAt, - precision = EvidencePrecision.INFERRED, - source = source, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - - private fun hasEarlierMembershipPeriod(db: SQLiteDatabase, uid: Long, membershipPeriodId: Long): Boolean = - db.rawQuery( - """ - SELECT 1 - FROM membership_periods current - JOIN membership_periods other ON other.uid = current.uid AND other.id != current.id - WHERE current.id = ? - AND current.uid = ? - AND ( - (current.joined_at IS NOT NULL AND other.joined_at IS NOT NULL AND - (other.joined_at < current.joined_at OR - (other.joined_at = current.joined_at AND other.id < current.id))) - OR - (current.joined_at IS NULL AND other.id < current.id) - ) - LIMIT 1 - """.trimIndent(), - arrayOf(membershipPeriodId.toString(), uid.toString()), - ).use(Cursor::moveToFirst) - - private fun memberExists(db: SQLiteDatabase, uid: Long): Boolean = - db.rawQuery( - "SELECT 1 FROM members WHERE uid = ? LIMIT 1", - arrayOf(uid.toString()), - ).use(Cursor::moveToFirst) - - private fun memberName(db: SQLiteDatabase, uid: Long): String? = - db.rawQuery( - "SELECT COALESCE(custom_name, current_name) FROM members WHERE uid = ?", - arrayOf(uid.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } - - private fun readMembershipPeriodForUpdate(db: SQLiteDatabase, membershipPeriodId: Long): MembershipPeriodForUpdate? = - db.rawQuery( - """ - SELECT uid, joined_at, left_at, joined_date, left_date, - joined_time_known, left_time_known, joined_source, left_source - FROM membership_periods - WHERE id = ? - """.trimIndent(), - arrayOf(membershipPeriodId.toString()), - ).use { cursor -> - if (!cursor.moveToFirst()) { - null - } else { - MembershipPeriodForUpdate( - uid = cursor.getLong(0), - joined = cursor.membershipBoundaryValue( - instantIndex = 1, - dateIndex = 3, - timeKnownIndex = 5, - zoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId), - ), - left = cursor.membershipBoundaryValue( - instantIndex = 2, - dateIndex = 4, - timeKnownIndex = 6, - zoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId), - ), - joinedSource = EvidenceSource.valueOf(cursor.getString(7)), - leftSource = cursor.getNullableString(8)?.let(EvidenceSource::valueOf), - ) - } - } - - private fun insertSnapshotMember( - db: SQLiteDatabase, - snapshotId: Long, - member: SnapshotMember, - ) { - db.insertOrThrow( - "snapshot_members", - null, - ContentValues().apply { - put("snapshot_id", snapshotId) - put("uid", member.uid) - put("name", member.name) - put("level", member.level) - put("weekly_merit", member.weeklyMerit) - put("total_merit", member.totalMerit) - put("high_score", member.highScore) - put("total_score", member.totalScore) - put("last_login", member.lastLogin) - }, - ) - } - - private fun upsertMember(db: SQLiteDatabase, member: SnapshotMember, observedAt: Instant) { - val existing = ContentValues().apply { - put("current_name", member.name) - put("current_level", member.level) - put("is_active", 1) - put("last_seen_at", observedAt.toEpochMilli()) - } - if (db.update("members", existing, "uid = ?", arrayOf(member.uid.toString())) == 0) { - existing.put("uid", member.uid) - existing.put("first_seen_at", observedAt.toEpochMilli()) - db.insertOrThrow("members", null, existing) - } - } - - private fun markInactive(db: SQLiteDatabase, uid: Long, observedAt: Instant) { - db.update( - "members", - ContentValues().apply { - put("is_active", 0) - put("last_seen_at", observedAt.toEpochMilli()) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - } - - private fun insertMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - joinedAt: Instant?, - precision: EvidencePrecision, - source: EvidenceSource, - ): Long = db.insertOrThrow( - "membership_periods", - null, - ContentValues().apply { - put("uid", uid) - putNullableLong("joined_at", joinedAt?.toEpochMilli()) - put("joined_precision", precision.name) - put("joined_source", source.name) - }, - ) - - private fun closeLatestMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - leftAt: Instant, - precision: EvidencePrecision, - source: EvidenceSource, - ): Long { - val membershipPeriodId = db.rawQuery( - "SELECT id FROM membership_periods WHERE uid = ? AND left_at IS NULL ORDER BY id DESC LIMIT 1", - arrayOf(uid.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - ?: error("Active member $uid has no open membershipPeriod") - db.update( - "membership_periods", - ContentValues().apply { - put("left_at", leftAt.toEpochMilli()) - putNull("left_date") - put("left_time_known", 1) - put("left_precision", precision.name) - put("left_source", source.name) - }, - "id = ?", - arrayOf(membershipPeriodId.toString()), - ) - return membershipPeriodId - } - - private fun insertEvent( - db: SQLiteDatabase, - uid: Long, - type: MemberEventType, - occurredAt: Instant?, - eventDate: LocalDate? = null, - timeKnown: Boolean = true, - observedAt: Instant, - precision: EvidencePrecision, - source: EvidenceSource, - note: String, - membershipPeriodId: Long? = null, - ): Long = db.insertOrThrow( - "member_events", - null, - ContentValues().apply { - put("uid", uid) - putNullableLong("membership_period_id", membershipPeriodId) - put("event_type", type.name) - putNullableLong("occurred_at", occurredAt?.toEpochMilli()) - putNullableLong("event_date", eventDate?.toEpochDay()) - put("time_known", if (timeKnown) 1 else 0) - put("observed_at", observedAt.toEpochMilli()) - put("precision", precision.name) - put("source", source.name) - put("note", note) - }, - ) - - private fun readKnownMembers(db: SQLiteDatabase): List = - db.rawQuery( - """ - SELECT m.uid, m.current_name, m.is_active, - EXISTS(SELECT 1 FROM membership_periods t WHERE t.uid = m.uid) - FROM members m - """.trimIndent(), - null, - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - SnapshotReconciler.KnownMember( - uid = cursor.getLong(0), - name = cursor.getString(1), - isActive = cursor.getInt(2) != 0, - hasPriorMembershipPeriod = cursor.getInt(3) != 0, - ), - ) - } - } - } - - - private fun readMembershipPeriods(db: SQLiteDatabase, uid: Long): List = - db.query( - "membership_periods", - MEMBERSHIP_PERIOD_COLUMNS, - "uid = ?", - arrayOf(uid.toString()), - null, - null, - "CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - MembershipPeriod( - id = cursor.getLong(0), - uid = uid, - joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), - leftAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli), - joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), - leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), - joinedTimeKnown = cursor.getInt(5) != 0, - leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 }, - joinedPrecision = enumValueOf(cursor.getString(7)), - leftPrecision = cursor.getNullableString(8) - ?.let(EvidencePrecision::valueOf), - joinedSource = enumValueOf(cursor.getString(9)), - leftSource = cursor.getNullableString(10) - ?.let(EvidenceSource::valueOf), - note = cursor.getString(11), - ), - ) - } - } - } - - private fun sourceFileExists(db: SQLiteDatabase, sourceFile: String): Boolean = - db.rawQuery( - "SELECT 1 FROM snapshots WHERE source_file = ? LIMIT 1", - arrayOf(sourceFile), - ).use { cursor -> cursor.moveToFirst() } - - private fun count(db: SQLiteDatabase, table: String): Long = - db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor -> - cursor.moveToFirst() - cursor.getLong(0) - } - - private fun Cursor.toMemberEvent(): MemberEvent = MemberEvent( - id = getLong(0), - uid = getLong(1), - type = enumValueOf(getString(2)), - occurredAt = getNullableLong(3)?.let(Instant::ofEpochMilli), - eventDate = getNullableLong(4)?.let(LocalDate::ofEpochDay), - timeKnown = getInt(5) != 0, - observedAt = Instant.ofEpochMilli(getLong(6)), - precision = enumValueOf(getString(7)), - source = enumValueOf(getString(8)), - note = getString(9), - ) - - private enum class MembershipBoundary { - JOIN, - WITHDRAW, - } - - private data class ActivityCandidate( - val id: Long, - val occurredAt: Instant, - ) - - private data class ResolvedActivityCandidate( - val id: Long, - val occurredAt: Instant, - val memberName: String, - val uid: Long, - ) - - private data class UnresolvedActivity( - val id: Long, - val occurredAt: Instant, - val memberName: String, - val actionId: Long, - val kind: Long, - ) - - private data class InferredBoundary( - val membershipPeriodId: Long, - val uid: Long, - val boundary: MembershipBoundary, - val inferredAt: Instant, - ) - - private data class BoundaryEventCandidate( - val id: Long, - val membershipPeriodId: Long?, - val boundaryAt: Instant, - ) - - private data class MembershipPeriodForUpdate( - val uid: Long, - val joined: MembershipBoundaryValue?, - val left: MembershipBoundaryValue?, - val joinedSource: EvidenceSource, - val leftSource: EvidenceSource?, - ) - - - private data class ShadowMembershipPeriod( - val joinedAt: Long?, - val leftAt: Long?, - val joinedDate: Long?, - val leftDate: Long?, - val joinedTimeKnown: Int?, - val leftTimeKnown: Int?, - val joinedPrecision: String?, - val leftPrecision: String?, - val joinedSource: String?, - val leftSource: String?, - ) - - private data class ManualBoundaryDateBackfill( - val id: Long, - val joinedAtMillis: Long?, - val leftAtMillis: Long?, - val joinedIsManual: Boolean, - val leftIsManual: Boolean, - ) - - private data class RosterIdentity( - val name: String, - val level: Int, - val capturedAt: Instant, - ) - - private data class RosterFrame( - val capturedAt: Instant, - val members: Map, - ) - - private data class PreservedMembershipPeriodOverlap( - val id: Long, - val joinedAt: Instant?, - val joinedPrecision: EvidencePrecision, - val joinedSource: EvidenceSource, - val leftAt: Instant?, - val leftPrecision: EvidencePrecision?, - val leftSource: EvidenceSource?, - ) - - private data class RosterPresenceRun( - val firstSeenAt: Instant, - val lastSeenAt: Instant, - val joinedAt: Instant?, - val leftAt: Instant?, - val name: String, - ) + internal fun replaceWeeklyReportHistory(replacements: List) = + WeeklyHistoryStore.replace(writableDatabase, replacements) companion object { + /** A fresh current-schema database that never touches storage, for schema comparison. */ + fun inMemory(context: Context, storageScope: PlatoonStorageScope) = + PlatoonDatabase(context, storageScope, null) + /** * A sparse positive signal: the member triggered a Daily Patrol supply * reward, which proves completion for that member and day. Its absence @@ -4025,151 +273,5 @@ internal class PlatoonDatabase( const val DAILY_PATROL_REWARD_ACTION_ID = 802001L internal const val MAX_STORED_ACTIVITY_OBSERVATIONS = 10_000 internal const val MAX_WEEKLY_REPORT_HISTORY = 15 - private val MANAGEMENT_DATA_TABLES = listOf( - "snapshots", - "members", - "membership_periods", - "member_events", - "platoon_activity", - "weekly_notes", - "weekly_overrides", - ) - private const val MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS = 250 - private const val ACTIVITY_RESOLUTION_CURSOR_KEY = "activity_resolution_cursor" - private const val DAILY_PATROL_RELATED_ACTION_ID = 801005L - private const val NAME_RESOLUTION_WINDOW_MILLIS = 30L * 24L * 60L * 60L * 1000L - private const val MEMBERSHIP_CORRELATION_WINDOW_MILLIS = 12L * 60L * 60L * 1000L - private const val EXACT_UPDATE_CORRELATION_WINDOW_MILLIS = - 48L * 60L * 60L * 1000L - private val SNAPSHOT_EVENT_SOURCES = setOf( - EvidenceSource.SNAPSHOT, - EvidenceSource.LEGACY_IMPORT, - ) - private val SNAPSHOT_COLUMNS = arrayOf("id", "captured_at", "source_file", "game_version") - private val MEMBER_COLUMNS = arrayOf( - "uid", - "COALESCE(custom_name, current_name)", - "current_level", - "is_active", - "first_seen_at", - "last_seen_at", - "note", - ) - private val EVENT_COLUMNS = arrayOf( - "id", - "uid", - "event_type", - "occurred_at", - "event_date", - "time_known", - "observed_at", - "precision", - "source", - "note", - ) - private val SNAPSHOT_MEMBER_COLUMNS = arrayOf( - "uid", - "name", - "level", - "weekly_merit", - "total_merit", - "high_score", - "total_score", - "last_login", - ) - private val MEMBERSHIP_PERIOD_COLUMNS = arrayOf( - "id", - "joined_at", - "left_at", - "joined_date", - "left_date", - "joined_time_known", - "left_time_known", - "joined_precision", - "left_precision", - "joined_source", - "left_source", - "note", - ) - private val WEEKLY_NOTE_COLUMNS = arrayOf( - "id", - "period_start", - "game_day", - "text", - "event_id", - "is_automatic", - ) - private val WEEKLY_OVERRIDE_COLUMNS = arrayOf( - "uid", - "period_start", - "game_day", - "merit_delta", - "score_delta", - "attempts", - "attended", - "daily_patrol", - ) } } - -internal data class WeeklyReportHistoryReplacement( - val periodStartEpochDay: Long, - val recordedAt: Instant, - val fingerprint: String, - val payload: ByteArray, -) - -private fun ContentValues.putNullableLong(key: String, value: Long?) { - if (value == null) putNull(key) else put(key, value) -} - -private fun ContentValues.putMembershipBoundary( - prefix: String, - boundary: MembershipBoundaryValue, -) { - put("${prefix}_at", boundary.instant.toEpochMilli()) - put("${prefix}_date", boundary.date.toEpochDay()) - put("${prefix}_time_known", if (boundary.timeKnown) 1 else 0) -} - -private fun ContentValues.clearMembershipBoundary(prefix: String) { - putNull("${prefix}_at") - putNull("${prefix}_date") - putNull("${prefix}_time_known") -} - -private fun ContentValues.putNullableInt(key: String, value: Int?) { - if (value == null) putNull(key) else put(key, value) -} - -private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) { - if (value == null) putNull(key) else put(key, if (value) 1 else 0) -} - -private fun Cursor.getNullableLong(index: Int): Long? = - if (isNull(index)) null else getLong(index) - -private fun Cursor.getNullableInt(index: Int): Int? = - if (isNull(index)) null else getInt(index) - -private fun Cursor.membershipBoundaryValue( - instantIndex: Int, - dateIndex: Int, - timeKnownIndex: Int, - zoneId: ZoneId, -): MembershipBoundaryValue? { - val instant = getNullableLong(instantIndex)?.let(Instant::ofEpochMilli) ?: return null - val date = getNullableLong(dateIndex)?.let(LocalDate::ofEpochDay) - ?: instant.atZone(zoneId).toLocalDate() - return MembershipBoundaryValue( - date = date, - instant = instant, - timeKnown = getNullableInt(timeKnownIndex)?.let { it != 0 } ?: true, - ) -} - -private fun Cursor.getNullableBoolean(index: Int): Boolean? = - if (isNull(index)) null else getInt(index) != 0 - -private fun Cursor.getNullableString(index: Int): String? = - if (isNull(index)) null else getString(index) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt new file mode 100644 index 0000000..f70a7ec --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt @@ -0,0 +1,462 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Upgrades older schemas inside SQLiteOpenHelper's upgrade transaction. */ +internal object PlatoonDatabaseMigrations { + fun upgrade(db: SQLiteDatabase, oldVersion: Int, gameZone: () -> ZoneId) { + if (oldVersion < 10) renameTenuresToMembershipPeriods(db, oldVersion) + if (oldVersion < 9) addCalendarDateColumns(db) + if (oldVersion < 2) moveWeeklyNotesToSundayPeriods(db) + if (oldVersion < 3) PlatoonDatabaseSchema.createWeeklyOverridesTable(db) + if (oldVersion < 4) db.execSQL("ALTER TABLE members ADD COLUMN custom_name TEXT") + if (oldVersion < 5) copyAutomaticNoteNamesToEvents(db) + if (oldVersion < 6) { + db.execSQL( + "ALTER TABLE member_events ADD COLUMN membership_period_id INTEGER " + + "REFERENCES membership_periods(id)", + ) + PlatoonDatabaseSchema.createPlatoonActivityTable(db) + db.delete("weekly_notes", "is_automatic = 1", null) + linkLegacyMembershipPeriodEvents(db) + } + if (oldVersion < 7) backfillSnapshotMembershipPeriodEvents(db) + if (oldVersion < 9) backfillManualCalendarDates(db, gameZone()) + if (oldVersion < 11) { + PlatoonDatabaseSchema.createPlatoonMaintenanceStateTable(db) + PlatoonDatabaseSchema.createPlatoonActivityRetentionIndex(db) + } + if (oldVersion < 12) PlatoonDatabaseSchema.createWeeklyReportHistoryTables(db) + if (oldVersion < 13) MembershipRows.synchronizeAllActiveStates(db) + } + + private fun renameTenuresToMembershipPeriods(db: SQLiteDatabase, oldVersion: Int) { + db.execSQL("ALTER TABLE tenures RENAME TO membership_periods") + db.execSQL("DROP INDEX IF EXISTS tenures_uid") + db.execSQL(PlatoonDatabaseSchema.MEMBERSHIP_PERIODS_UID_INDEX) + if (oldVersion >= 6) { + migrateMembershipPeriodEventReference( + db = db, + hasCalendarColumns = oldVersion >= 9, + ) + } + } + + private fun addCalendarDateColumns(db: SQLiteDatabase) { + db.execSQL("ALTER TABLE membership_periods ADD COLUMN joined_date INTEGER") + db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_date INTEGER") + db.execSQL( + "ALTER TABLE membership_periods ADD COLUMN joined_time_known INTEGER NOT NULL DEFAULT 1", + ) + db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_time_known INTEGER") + db.execSQL("ALTER TABLE member_events ADD COLUMN event_date INTEGER") + db.execSQL( + "ALTER TABLE member_events ADD COLUMN time_known INTEGER NOT NULL DEFAULT 1", + ) + } + + private fun moveWeeklyNotesToSundayPeriods(db: SQLiteDatabase) { + val correctedPeriods = mutableListOf>() + db.query( + "weekly_notes", + arrayOf("id", "game_day"), + null, + null, + null, + null, + null, + ).use { cursor -> + while (cursor.moveToNext()) { + correctedPeriods += cursor.getLong(0) to + PlatoonPeriods.weekStart(LocalDate.ofEpochDay(cursor.getLong(1))).toEpochDay() + } + } + correctedPeriods.forEach { (id, periodStart) -> + db.update( + "weekly_notes", + ContentValues().apply { put("period_start", periodStart) }, + "id = ?", + arrayOf(id.toString()), + ) + } + } + + private fun copyAutomaticNoteNamesToEvents(db: SQLiteDatabase) { + val historicalNames = mutableListOf>() + db.query( + "weekly_notes", + arrayOf("event_id", "text"), + "is_automatic = 1 AND event_id IS NOT NULL", + null, + null, + null, + "id", + ).use { cursor -> + while (cursor.moveToNext()) { + val parts = cursor.getString(1).split(':', limit = 3) + if (parts.size == 3 && parts[1].isNotBlank()) { + historicalNames += cursor.getLong(0) to parts[1] + } + } + } + historicalNames.forEach { (eventId, name) -> + db.update( + "member_events", + ContentValues().apply { put("note", name) }, + "id = ? AND note = ''", + arrayOf(eventId.toString()), + ) + } + } + + /** + * Rebuilds member_events with the membership-period reference, then rebuilds dependent tables + * so every foreign key targets the replacement. All rows and IDs are preserved. + */ + private fun migrateMembershipPeriodEventReference( + db: SQLiteDatabase, + hasCalendarColumns: Boolean, + ) { + db.execSQL("ALTER TABLE member_events RENAME TO member_events_legacy_v10") + db.execSQL("ALTER TABLE weekly_notes RENAME TO weekly_notes_legacy_v10") + db.execSQL("ALTER TABLE platoon_activity RENAME TO platoon_activity_legacy_v10") + db.execSQL("DROP INDEX IF EXISTS member_events_time") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_member_time") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_action_time") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_exact_identity") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_unresolved_identity") + + db.execSQL(PlatoonDatabaseSchema.memberEventsTable(withCalendarColumns = hasCalendarColumns)) + val calendarNames = if (hasCalendarColumns) ", event_date, time_known" else "" + db.execSQL( + """ + INSERT INTO member_events( + id, uid, membership_period_id, event_type, occurred_at$calendarNames, + observed_at, precision, source, note + ) + SELECT id, uid, tenure_id, event_type, occurred_at$calendarNames, + observed_at, precision, source, note + FROM member_events_legacy_v10 + """.trimIndent(), + ) + db.execSQL(PlatoonDatabaseSchema.MEMBER_EVENTS_TIME_INDEX) + + db.execSQL(PlatoonDatabaseSchema.WEEKLY_NOTES_TABLE) + db.execSQL( + """ + INSERT INTO weekly_notes(id, period_start, game_day, text, event_id, is_automatic) + SELECT id, period_start, game_day, text, event_id, is_automatic + FROM weekly_notes_legacy_v10 + """.trimIndent(), + ) + + PlatoonDatabaseSchema.createPlatoonActivityTable(db) + db.execSQL( + """ + INSERT OR IGNORE INTO platoon_activity( + id, occurred_at, action_id, kind, member_name, captured_at, + resolved_uid, resolution, member_event_id + ) + SELECT id, occurred_at, action_id, kind, member_name, captured_at, + resolved_uid, resolution, member_event_id + FROM platoon_activity_legacy_v10 + ORDER BY CASE WHEN member_event_id IS NULL THEN 1 ELSE 0 END, id + """.trimIndent(), + ) + db.execSQL("DROP TABLE weekly_notes_legacy_v10") + db.execSQL("DROP TABLE platoon_activity_legacy_v10") + db.execSQL("DROP TABLE member_events_legacy_v10") + } + + private fun linkLegacyMembershipPeriodEvents(db: SQLiteDatabase) { + db.rawQuery( + """ + SELECT period.id, period.uid, period.joined_at, period.left_at, + period.joined_precision, period.left_precision, + period.joined_source, period.left_source, + COALESCE(m.custom_name, m.current_name) + FROM membership_periods period + JOIN members m ON m.uid = period.uid + ORDER BY period.uid, period.id + """.trimIndent(), + null, + ).use { cursor -> + while (cursor.moveToNext()) { + val membershipPeriodId = cursor.getLong(0) + val uid = cursor.getLong(1) + val joinedAt = cursor.getNullableLong(2) + val leftAt = cursor.getNullableLong(3) + val joinedPrecision = EvidencePrecision.valueOf(cursor.getString(4)) + val leftPrecision = cursor.getNullableString(5)?.let(EvidencePrecision::valueOf) + val joinedSource = EvidenceSource.valueOf(cursor.getString(6)) + val leftSource = cursor.getNullableString(7)?.let(EvidenceSource::valueOf) + val memberName = cursor.getString(8) + if (joinedAt != null) { + val expectedType = MembershipRows.joinEventType(db, uid, membershipPeriodId) + val linked = linkLegacyBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + occurredAt = joinedAt, + precision = joinedPrecision, + source = joinedSource, + expectedType = expectedType, + ) + if (!linked && joinedSource == EvidenceSource.MANUAL) { + insertLegacyManualBoundaryEvent( + db, + membershipPeriodId, + uid, + memberName, + expectedType, + joinedAt, + joinedPrecision, + ) + } + } + if (leftAt != null && leftPrecision != null && leftSource != null) { + val linked = linkLegacyBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + occurredAt = leftAt, + precision = leftPrecision, + source = leftSource, + expectedType = MemberEventType.LEFT, + ) + if (!linked && leftSource == EvidenceSource.MANUAL) { + insertLegacyManualBoundaryEvent( + db, + membershipPeriodId, + uid, + memberName, + MemberEventType.LEFT, + leftAt, + leftPrecision, + ) + } + } + } + } + } + + private fun linkLegacyBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + occurredAt: Long, + precision: EvidencePrecision, + source: EvidenceSource, + expectedType: MemberEventType, + ): Boolean { + val candidates = db.rawQuery( + """ + SELECT id, event_type, occurred_at, observed_at, source + FROM member_events + WHERE membership_period_id IS NULL + AND uid = ? + AND source = ? + AND precision = ? + ORDER BY id + """.trimIndent(), + arrayOf( + uid.toString(), + source.name, + precision.name, + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + LegacyEventCandidate( + id = cursor.getLong(0), + type = MemberEventType.valueOf(cursor.getString(1)), + occurredAt = cursor.getNullableLong(2), + observedAt = cursor.getLong(3), + source = EvidenceSource.valueOf(cursor.getString(4)), + ), + ) + } + } + } + val eventId = LegacyEventMigrationPolicy.selectCandidate( + expectedType, + occurredAt, + candidates, + ) + ?: return false + db.update( + "member_events", + ContentValues().apply { put("membership_period_id", membershipPeriodId) }, + "id = ?", + arrayOf(eventId.toString()), + ) + return true + } + + private fun insertLegacyManualBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + occurredAt: Long, + precision: EvidencePrecision, + ) { + val boundary = Instant.ofEpochMilli(occurredAt) + MembershipRows.insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = boundary, + observedAt = boundary, + precision = precision, + source = EvidenceSource.MANUAL, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + } + + private fun backfillManualCalendarDates(db: SQLiteDatabase, zoneId: ZoneId) { + val membershipPeriods = db.rawQuery( + """ + SELECT id, joined_at, left_at, joined_source, left_source + FROM membership_periods + WHERE (joined_source = ? AND joined_at IS NOT NULL) + OR (left_source = ? AND left_at IS NOT NULL) + """.trimIndent(), + arrayOf(EvidenceSource.MANUAL.name, EvidenceSource.MANUAL.name), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + ManualBoundaryDateBackfill( + id = cursor.getLong(0), + joinedAtMillis = cursor.getNullableLong(1), + leftAtMillis = cursor.getNullableLong(2), + joinedIsManual = cursor.getString(3) == EvidenceSource.MANUAL.name, + leftIsManual = + cursor.getNullableString(4) == EvidenceSource.MANUAL.name, + ), + ) + } + } + } + membershipPeriods.forEach { membershipPeriod -> + db.update( + "membership_periods", + ContentValues().apply { + if (membershipPeriod.joinedIsManual && membershipPeriod.joinedAtMillis != null) { + put( + "joined_date", + migrationCalendarDate(membershipPeriod.joinedAtMillis, zoneId).toEpochDay(), + ) + put("joined_time_known", 1) + } + if (membershipPeriod.leftIsManual && membershipPeriod.leftAtMillis != null) { + put( + "left_date", + migrationCalendarDate(membershipPeriod.leftAtMillis, zoneId).toEpochDay(), + ) + put("left_time_known", 1) + } + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + } + + val events = db.rawQuery( + """ + SELECT id, occurred_at + FROM member_events + WHERE source = ? + AND occurred_at IS NOT NULL + """.trimIndent(), + arrayOf(EvidenceSource.MANUAL.name), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getLong(1)) + } + } + events.forEach { (id, occurredAtMillis) -> + db.update( + "member_events", + ContentValues().apply { + put( + "event_date", + migrationCalendarDate(occurredAtMillis, zoneId).toEpochDay(), + ) + put("time_known", 1) + }, + "id = ?", + arrayOf(id.toString()), + ) + } + } + + private fun backfillSnapshotMembershipPeriodEvents(db: SQLiteDatabase) { + db.rawQuery( + """ + SELECT period.id, period.uid, period.joined_at, period.left_at, + period.joined_source, period.left_source, + COALESCE(m.custom_name, m.current_name) + FROM membership_periods period + JOIN members m ON m.uid = period.uid + WHERE period.joined_source IN (?, ?) + OR period.left_source IN (?, ?) + ORDER BY period.uid, period.id + """.trimIndent(), + arrayOf( + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + ), + ).use { cursor -> + while (cursor.moveToNext()) { + val membershipPeriodId = cursor.getLong(0) + val uid = cursor.getLong(1) + val joinedAt = cursor.getNullableInstant(2) + val leftAt = cursor.getNullableInstant(3) + val joinedSource = EvidenceSource.valueOf(cursor.getString(4)) + val leftSource = cursor.getNullableString(5)?.let(EvidenceSource::valueOf) + val name = cursor.getString(6) + if (joinedAt != null && joinedSource in SNAPSHOT_EVENT_SOURCES) { + MembershipRows.insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + name, + MembershipRows.joinEventType(db, uid, membershipPeriodId), + joinedAt, + joinedSource, + ) + } + if (leftAt != null && leftSource in SNAPSHOT_EVENT_SOURCES) { + MembershipRows.insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + name, + MemberEventType.LEFT, + leftAt, + requireNotNull(leftSource), + ) + } + } + } + } + + private data class ManualBoundaryDateBackfill( + val id: Long, + val joinedAtMillis: Long?, + val leftAtMillis: Long?, + val joinedIsManual: Boolean, + val leftIsManual: Boolean, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt new file mode 100644 index 0000000..1700068 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt @@ -0,0 +1,240 @@ +package dev.gf2log.app.management + +import android.database.sqlite.SQLiteDatabase + +/** Owns the current DDL; definitions shared by onCreate and migrations live here once. */ +internal object PlatoonDatabaseSchema { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at INTEGER NOT NULL, + source_file TEXT UNIQUE, + game_version TEXT + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX snapshots_captured_at ON snapshots(captured_at DESC)") + db.execSQL( + """ + CREATE TABLE snapshot_members ( + snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE, + uid INTEGER NOT NULL, + name TEXT NOT NULL, + level INTEGER NOT NULL, + weekly_merit INTEGER NOT NULL, + total_merit INTEGER NOT NULL, + high_score INTEGER NOT NULL, + total_score INTEGER NOT NULL, + last_login INTEGER NOT NULL, + PRIMARY KEY(snapshot_id, uid) + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE members ( + uid INTEGER PRIMARY KEY, + current_name TEXT NOT NULL, + custom_name TEXT, + current_level INTEGER NOT NULL, + is_active INTEGER NOT NULL, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '' + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE membership_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER NOT NULL REFERENCES members(uid), + joined_at INTEGER, + left_at INTEGER, + joined_date INTEGER, + left_date INTEGER, + joined_time_known INTEGER NOT NULL DEFAULT 1, + left_time_known INTEGER, + joined_precision TEXT NOT NULL, + left_precision TEXT, + joined_source TEXT NOT NULL, + left_source TEXT, + note TEXT NOT NULL DEFAULT '' + ) + """.trimIndent(), + ) + db.execSQL(MEMBERSHIP_PERIODS_UID_INDEX) + db.execSQL(memberEventsTable(withCalendarColumns = true)) + db.execSQL(MEMBER_EVENTS_TIME_INDEX) + createPlatoonActivityTable(db) + db.execSQL(WEEKLY_NOTES_TABLE) + createWeeklyOverridesTable(db) + createPlatoonMaintenanceStateTable(db) + createWeeklyReportHistoryTables(db) + } + + /** + * The member_events definition shared by onCreate and the v10 rebuild. Databases older than + * v9 are rebuilt without the calendar columns, which the v9 step then appends. + */ + fun memberEventsTable(withCalendarColumns: Boolean): String = table( + "member_events", + listOfNotNull( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "uid INTEGER NOT NULL REFERENCES members(uid)", + "membership_period_id INTEGER REFERENCES membership_periods(id)", + "event_type TEXT NOT NULL", + "occurred_at INTEGER", + "event_date INTEGER".takeIf { withCalendarColumns }, + "time_known INTEGER NOT NULL DEFAULT 1".takeIf { withCalendarColumns }, + "observed_at INTEGER NOT NULL", + "precision TEXT NOT NULL", + "source TEXT NOT NULL", + "note TEXT NOT NULL DEFAULT ''", + ), + ) + + val WEEKLY_NOTES_TABLE = table( + "weekly_notes", + listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "period_start INTEGER NOT NULL", + "game_day INTEGER NOT NULL", + "text TEXT NOT NULL", + "event_id INTEGER REFERENCES member_events(id) ON DELETE SET NULL", + "is_automatic INTEGER NOT NULL DEFAULT 0", + ), + ) + + const val MEMBERSHIP_PERIODS_UID_INDEX = + "CREATE INDEX membership_periods_uid ON membership_periods(uid, id DESC)" + const val MEMBER_EVENTS_TIME_INDEX = + "CREATE INDEX member_events_time ON member_events(observed_at DESC)" + + private fun table(name: String, columns: List): String = + "CREATE TABLE $name (\n ${columns.joinToString(",\n ")}\n)" + + /** Idempotent maintenance objects that every writable open guarantees. */ + fun ensureMaintenanceObjects(db: SQLiteDatabase) { + createPlatoonMaintenanceStateTable(db) + createPlatoonActivityRetentionIndex(db) + createWeeklyReportHistoryTables(db) + } + + fun createWeeklyReportHistoryTables(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS weekly_report_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_start INTEGER NOT NULL, + recorded_at INTEGER NOT NULL, + fingerprint TEXT NOT NULL, + report_blob BLOB NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS weekly_report_history_period " + + "ON weekly_report_history(period_start, recorded_at DESC, id DESC)", + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS weekly_report_history_state ( + period_start INTEGER PRIMARY KEY, + active_history_id INTEGER REFERENCES weekly_report_history(id) ON DELETE SET NULL + ) + """.trimIndent(), + ) + } + + fun createWeeklyOverridesTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS weekly_overrides ( + uid INTEGER NOT NULL, + period_start INTEGER NOT NULL, + game_day INTEGER NOT NULL, + merit_delta INTEGER, + score_delta INTEGER, + attempts INTEGER, + attended INTEGER, + daily_patrol INTEGER, + PRIMARY KEY(uid, game_day) + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS weekly_overrides_period " + + "ON weekly_overrides(period_start, game_day)", + ) + } + + fun createPlatoonActivityTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS platoon_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at INTEGER NOT NULL, + action_id INTEGER NOT NULL, + kind INTEGER NOT NULL, + member_name TEXT NOT NULL, + captured_at INTEGER NOT NULL, + resolved_uid INTEGER REFERENCES members(uid), + resolution TEXT NOT NULL DEFAULT 'UNRESOLVED', + member_event_id INTEGER REFERENCES member_events(id) + ) + """.trimIndent(), + ) + createPlatoonActivityIndexes(db) + } + + private fun createPlatoonActivityIndexes(db: SQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_member_time " + + "ON platoon_activity(member_name, occurred_at)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_action_time " + + "ON platoon_activity(action_id, occurred_at)", + ) + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_exact_identity + ON platoon_activity(occurred_at, action_id, kind, resolved_uid) + WHERE resolved_uid IS NOT NULL + """.trimIndent(), + ) + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_unresolved_identity + ON platoon_activity(occurred_at, action_id, kind, member_name) + WHERE resolved_uid IS NULL + """.trimIndent(), + ) + createPlatoonActivityRetentionIndex(db) + } + + fun createPlatoonActivityRetentionIndex(db: SQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_resolution_retention " + + "ON platoon_activity(resolved_uid, captured_at DESC, id DESC)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_retention_order " + + "ON platoon_activity(captured_at DESC, id DESC)", + ) + } + + fun createPlatoonMaintenanceStateTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS platoon_maintenance_state ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ) + """.trimIndent(), + ) + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt new file mode 100644 index 0000000..31c9c5e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt @@ -0,0 +1,111 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Snapshot-derived evidence is weak: replay may rebuild it from retained rosters. */ +internal val SNAPSHOT_EVENT_SOURCES = setOf(EvidenceSource.SNAPSHOT, EvidenceSource.LEGACY_IMPORT) + +/** One side of a membership period with its column prefix and the events that record it. */ +internal enum class MembershipBoundary( + val prefix: String, + val eventTypes: List, +) { + JOIN("joined", listOf(MemberEventType.JOINED, MemberEventType.REJOINED)), + WITHDRAW("left", listOf(MemberEventType.LEFT, MemberEventType.REMOVED)), + ; + + val atColumn: String + get() = "${prefix}_at" + + val precisionColumn: String + get() = "${prefix}_precision" + + val sourceColumn: String + get() = "${prefix}_source" + + val opposite: MembershipBoundary + get() = if (this == JOIN) WITHDRAW else JOIN +} + +/** + * Commits only when [block] completes normally. An exception or a non-local `return` rolls the + * transaction back; an early result that must commit returns through `return@inTransaction`. + * A nested block that rolls back discards its whole outer transaction, so read-only early exits + * also return through `return@inTransaction`. + */ +internal inline fun SQLiteDatabase.inTransaction(block: () -> T): T { + beginTransaction() + try { + val result = block() + setTransactionSuccessful() + return result + } finally { + endTransaction() + } +} + +internal fun SQLiteDatabase.exists(sql: String, vararg arguments: Any): Boolean = + rawQuery(sql, arguments.map(Any::toString).toTypedArray()).use(Cursor::moveToFirst) + +internal fun ContentValues.putNullableLong(key: String, value: Long?) { + if (value == null) putNull(key) else put(key, value) +} + +internal fun ContentValues.putMembershipBoundary( + prefix: String, + boundary: MembershipBoundaryValue, +) { + put("${prefix}_at", boundary.instant.toEpochMilli()) + put("${prefix}_date", boundary.date.toEpochDay()) + put("${prefix}_time_known", if (boundary.timeKnown) 1 else 0) +} + +internal fun ContentValues.clearMembershipBoundary(prefix: String) { + putNull("${prefix}_at") + putNull("${prefix}_date") + putNull("${prefix}_time_known") +} + +internal fun ContentValues.putNullableInt(key: String, value: Int?) { + if (value == null) putNull(key) else put(key, value) +} + +internal fun ContentValues.putNullableBoolean(key: String, value: Boolean?) { + if (value == null) putNull(key) else put(key, if (value) 1 else 0) +} + +internal fun Cursor.getNullableLong(index: Int): Long? = + if (isNull(index)) null else getLong(index) + +internal fun Cursor.getNullableInt(index: Int): Int? = + if (isNull(index)) null else getInt(index) + +internal fun Cursor.getNullableBoolean(index: Int): Boolean? = + if (isNull(index)) null else getInt(index) != 0 + +internal fun Cursor.getNullableString(index: Int): String? = + if (isNull(index)) null else getString(index) + +internal fun Cursor.getNullableInstant(index: Int): Instant? = + getNullableLong(index)?.let(Instant::ofEpochMilli) + +internal fun Cursor.membershipBoundaryValue( + instantIndex: Int, + dateIndex: Int, + timeKnownIndex: Int, + zoneId: ZoneId, +): MembershipBoundaryValue? { + val instant = getNullableInstant(instantIndex) ?: return null + val date = getNullableLong(dateIndex)?.let(LocalDate::ofEpochDay) + ?: instant.atZone(zoneId).toLocalDate() + return MembershipBoundaryValue( + date = date, + instant = instant, + timeKnown = getNullableInt(timeKnownIndex)?.let { it != 0 } ?: true, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt index 4c7bbbc..b1d04d1 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt @@ -8,11 +8,13 @@ data class SnapshotIngestResult( val rejoined: Int, val left: Int, val renamed: Int, + /** True when the roster predated current data and was replayed into history instead. */ + val historical: Boolean = false, ) { companion object { fun duplicate() = SnapshotIngestResult(null, true, false, 0, 0, 0, 0) fun historical(snapshotId: Long) = - SnapshotIngestResult(snapshotId, false, false, 0, 0, 0, 0) + SnapshotIngestResult(snapshotId, false, false, 0, 0, 0, 0, historical = true) } } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt index 93a5910..b106712 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.management import dev.gf2log.protocol.CsvCell +import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -17,7 +19,7 @@ object PlatoonMemberCsv { appendLine(HEADER) statuses.forEach { status -> val latest = latestMembers[status.uid] - val membershipPeriod = status.membershipPeriods.firstOrNull() + val membershipPeriod = status.latestMembershipPeriod() appendLine( listOf( status.uid, @@ -52,8 +54,8 @@ object PlatoonMemberCsv { }.trimEnd() + "\n" private fun formatBoundary( - date: java.time.LocalDate?, - instant: java.time.Instant?, + date: LocalDate?, + instant: Instant?, timeKnown: Boolean, zoneId: ZoneId, ): String { diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt index cf78876..e245cc8 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -11,6 +11,8 @@ data class PlatoonSnapshot( val gameVersion: String? = null, ) +internal fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = members.firstOrNull { it.uid == uid } + data class SnapshotMember( val uid: Long, val name: String, @@ -49,6 +51,19 @@ data class MemberStatus( val membershipPeriods: List, ) +/** + * The membership period a member list shows: the open period, or the most recently closed one + * when the member has withdrawn. Unknown boundaries sort before every known one. + */ +fun MemberStatus.latestMembershipPeriod(): MembershipPeriod? { + val open = membershipPeriods.filter { it.leftAt == null } + return (open.ifEmpty { membershipPeriods }).maxWithOrNull( + compareBy(nullsFirst()) { it.leftAt } + .thenBy(nullsFirst()) { it.joinedAt } + .thenBy { it.id }, + ) +} + data class MemberEvent( val id: Long, val uid: Long, @@ -62,17 +77,6 @@ data class MemberEvent( val note: String, ) -data class PlatoonActivityFact( - val id: Long, - val occurredAt: Instant, - val actionId: Long, - val kind: Long, - val memberName: String, - val capturedAt: Instant, - val resolvedUid: Long?, - val resolution: ActivityResolution, -) - data class PlatoonActivityObservation( val occurredAt: Instant, val actionId: Long, @@ -127,14 +131,6 @@ enum class EvidenceSource { LEGACY_IMPORT, } -data class DailyActivity( - val uid: Long, - val gameDay: LocalDate, - val meritDelta: Long, - val scoreDelta: Long, - val inference: ActivityInference.Result, -) - enum class DailyEvidence { NO_OBSERVATION, ATTRIBUTED, @@ -150,6 +146,10 @@ enum class MetricCertainty { EXACT, } +/** EXACT when a value's bounds coincide, otherwise only its minimum is known. */ +internal fun rangeCertainty(minimum: Number, maximum: Number): MetricCertainty = + if (minimum.toLong() == maximum.toLong()) MetricCertainty.EXACT else MetricCertainty.LOWER_BOUND + data class WeeklyNote( val id: Long, val periodStart: LocalDate, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt index 2912c0b..15ca84c 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.model.PlatoonActivityData import dev.gf2log.protocol.model.PlatoonUpdatesData import java.time.Instant @@ -9,6 +10,10 @@ internal object PlatoonObservationPolicy { const val MAX_UPDATE_OBSERVATIONS = 250 const val MAX_ACTIVITY_MEMBER_NAME_LENGTH = 128 + /** An Updates name becomes a member and report name, so it obeys the roster bound. */ + const val MAX_UPDATE_MEMBER_NAME_LENGTH = GuildMembersCsv.MAX_MEMBER_NAME_CHARS + const val MAX_UPDATE_MEMBERS_PER_OBSERVATION = GuildMembersCsv.MAX_ROSTER_MEMBERS + fun activity(data: PlatoonActivityData): List = activity( data.entries.mapNotNull { entry -> if ( @@ -68,7 +73,8 @@ internal object PlatoonObservationPolicy { observations: List, ): List = observations.asSequence() .filter { - it.members.isNotEmpty() && + it.members.size in 1..MAX_UPDATE_MEMBERS_PER_OBSERVATION && + it.members.all { member -> member.name.length <= MAX_UPDATE_MEMBER_NAME_LENGTH } && PlatoonUpdateSemantics.effect(it.kind) != PlatoonUpdateEffect.IGNORE } .distinctBy { observation -> diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt index 1eefba3..fb1b1de 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt @@ -14,8 +14,13 @@ object PlatoonPeriods { const val GUNSMOKE_FINAL_SCORE_HOUR = 2 val GUNSMOKE_ANCHOR: LocalDate = LocalDate.of(2026, 7, 19) + /** + * The 05:00-based game day containing [instant]. The reset is a wall-clock time, so the + * shift is applied to the local date-time; shifting the instant would move the boundary by + * an hour on daylight-saving transition days and disagree with [periodStartInstant]. + */ fun gameDay(instant: Instant, zoneId: ZoneId): LocalDate = - instant.atZone(zoneId).minusHours(RESET_HOUR.toLong()).toLocalDate() + instant.atZone(zoneId).toLocalDateTime().minusHours(RESET_HOUR.toLong()).toLocalDate() fun weekStart(gameDay: LocalDate): LocalDate = gameDay.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index e9f5b7f..ecb9fb2 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -1,12 +1,12 @@ package dev.gf2log.app.management import android.content.Context +import android.content.SharedPreferences import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.protocol.model.PlatoonProfileData import java.io.File -import java.security.MessageDigest import java.security.SecureRandom import java.time.Instant @@ -62,6 +62,10 @@ internal data class PlatoonProfile( } } + /** True for the one non-legacy profile that owns this verified full identity. */ + fun hasIdentity(client: PlatoonClient, region: GameServerRegion, platoonId: Long): Boolean = + !legacy && this.client == client && serverRegion == region && this.platoonId == platoonId + companion object { const val MAX_BANNER_ID = 6L const val MAX_NAME_LENGTH = 128 @@ -79,17 +83,12 @@ internal object PlatoonProfileIdentity { require(platoonId > 0L) val material = "${client.packageName}\u0000${region.storedValue}\u0000$platoonId" .toByteArray(Charsets.UTF_8) - return MessageDigest.getInstance("SHA-256") - .digest(material) - .take(16) - .joinToString("") { value -> "%02x".format(value) } + return material.sha256().copyOf(16).toHex() } fun isValidStorageId(value: String): Boolean = STORAGE_ID.matches(value) - fun randomStorageId(): String = ByteArray(16) - .also(SecureRandom()::nextBytes) - .joinToString("") { value -> "%02x".format(value) } + fun randomStorageId(): String = ByteArray(16).also(SecureRandom()::nextBytes).toHex() } /** Resolves every database and retained-evidence path from one validated profile ID. */ @@ -113,6 +112,10 @@ internal data class PlatoonStorageScope(val storageId: String) { fun retainedCsvDirectory(context: Context): File = File(rootDirectory(context), PlatoonRepository.RETAINED_CSV_DIRECTORY) + /** Retained evidence parked by an uncommitted complete restore. */ + fun previousRetainedCsvDirectory(context: Context): File = + File(rootDirectory(context), "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore") + companion object { private val SCOPED_DATABASE = Regex("platoon-([0-9a-f]{32})\\.db") @@ -132,29 +135,21 @@ internal class PlatoonProfileRegistry(context: Context) { private val appContext = context.applicationContext private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - fun ensureInitialized(): List = synchronized(lock) { - PlatoonProfileAdministration.recoverPending(appContext) - retireLegacyMetadataLocked() - readAllLocked().filterNot(PlatoonProfile::legacy) - } + fun ensureInitialized(): List = initialized { it } - fun list(): List = synchronized(lock) { - ensureInitialized() - readAllLocked().filterNot(PlatoonProfile::legacy).sortedWith( + fun list(): List = initialized { profiles -> + profiles.sortedWith( compareByDescending { it.lastSeenAt }.thenBy { it.storageId }, ) } - fun active(): PlatoonProfile? = synchronized(lock) { - ensureInitialized() + fun active(): PlatoonProfile? = initialized { current -> val activeId = preferences.getString(KEY_ACTIVE, null) - val current = readAllLocked().filterNot(PlatoonProfile::legacy) current.firstOrNull { it.storageId == activeId } ?: current.maxByOrNull(PlatoonProfile::lastSeenAt) } - fun find(storageId: String): PlatoonProfile? = synchronized(lock) { - ensureInitialized() + fun find(storageId: String): PlatoonProfile? = initialized { readLocked(storageId)?.takeUnless(PlatoonProfile::legacy) } @@ -162,23 +157,27 @@ internal class PlatoonProfileRegistry(context: Context) { client: PlatoonClient, region: GameServerRegion, platoonId: Long, - ): PlatoonProfile? = synchronized(lock) { - ensureInitialized() - readAllLocked().firstOrNull { - !it.legacy && - it.client == client && - it.serverRegion == region && - it.platoonId == platoonId - } + ): PlatoonProfile? = initialized { profiles -> + profiles.firstOrNull { it.hasIdentity(client, region, platoonId) } } fun findByClientAndPlatoonId( client: PlatoonClient, platoonId: Long, - ): List = synchronized(lock) { - ensureInitialized() - readAllLocked().filter { - !it.legacy && it.client == client && it.platoonId == platoonId + ): List = initialized { profiles -> + profiles.filter { it.client == client && it.platoonId == platoonId } + } + + /** + * Global lock order is PlatoonRepository maintenance lock -> registry [lock]. Finishing a + * pending deletion takes the exclusive maintenance lock, so it runs before [lock] is held; + * restores already hold the maintenance lock when they update registry metadata. + */ + private inline fun initialized(block: (List) -> T): T { + PlatoonProfileAdministration.recoverPending(appContext) + return synchronized(lock) { + retireLegacyMetadataLocked() + block(readAllLocked().filterNot(PlatoonProfile::legacy)) } } @@ -217,22 +216,10 @@ internal class PlatoonProfileRegistry(context: Context) { require(region != GameServerRegion.MANUAL) { "A server region is required" } require(data.platoonId != 0u && data.platoonName.isNotBlank()) val platoonId = data.platoonId.toLong() - val existing = readAllLocked().firstOrNull { - !it.legacy && - it.client == client && - it.serverRegion == region && - it.platoonId == platoonId - } + val existing = readAllLocked().firstOrNull { it.hasIdentity(client, region, platoonId) } val storageId = existing?.storageId ?: allocateStorageIdLocked(client, region, platoonId) - require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } - val normalizedName = data.platoonName - .replace(Regex("\\s+"), " ") - .filterNot(Char::isISOControl) - .trim() - .take(PlatoonProfile.MAX_NAME_LENGTH) - require(normalizedName.isNotBlank()) { "Platoon name is empty after normalization" } + requireCapacityLocked(storageId) + val normalizedName = normalizeName(data.platoonName) val bannerIds = normalizedBannerIds( data = data, fallbackFrameId = existing?.bannerFrameId ?: 0L, @@ -268,17 +255,13 @@ internal class PlatoonProfileRegistry(context: Context) { "The server region does not belong to this client" } require(platoonId in 1L..UInt.MAX_VALUE.toLong()) { "Platoon ID is invalid" } - require(readAllLocked().none { - !it.legacy && - it.client == client && - it.serverRegion == region && - it.platoonId == platoonId - }) { "That client/server Platoon profile already exists" } - require(readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" + require(readAllLocked().none { it.hasIdentity(client, region, platoonId) }) { + "That client/server Platoon profile already exists" } + val storageId = allocateStorageIdLocked(client, region, platoonId) + requireCapacityLocked(storageId) val profile = PlatoonProfile( - storageId = allocateStorageIdLocked(client, region, platoonId), + storageId = storageId, client = client, serverRegion = region, platoonId = platoonId, @@ -328,10 +311,7 @@ internal class PlatoonProfileRegistry(context: Context) { require( readAllLocked().none { it.storageId != storageId && - !it.legacy && - it.client == current.client && - it.serverRegion == region && - it.platoonId == current.platoonId + it.hasIdentity(current.client, region, current.platoonId) }, ) { "That client/server Platoon profile already exists" } val updated = current.copy(serverRegion = region) @@ -350,9 +330,7 @@ internal class PlatoonProfileRegistry(context: Context) { fun upsertRestored(profile: PlatoonProfile): PlatoonProfile = synchronized(lock) { require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } requireCompatibleRestoreTargetLocked(profile) - require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } + requireCapacityLocked(profile.storageId) writeLocked(profile, setActive = false) profile } @@ -361,9 +339,7 @@ internal class PlatoonProfileRegistry(context: Context) { fun requireRestoreCapacity(profile: PlatoonProfile) = synchronized(lock) { require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } requireCompatibleRestoreTargetLocked(profile) - require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } + requireCapacityLocked(profile.storageId) } /** Removes registry metadata introduced by a failed restore; scoped data is left untouched. */ @@ -373,19 +349,9 @@ internal class PlatoonProfileRegistry(context: Context) { if (preferences.getString(KEY_ACTIVE, null) == storageId) return@synchronized false val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() if (!ids.remove(storageId)) return@synchronized false - val prefix = "$KEY_PROFILE.$storageId." preferences.edit() .putStringSet(KEY_IDS, ids) - .remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + .removeProfileKeys(storageId) .commit() } @@ -402,32 +368,13 @@ internal class PlatoonProfileRegistry(context: Context) { PlatoonProfileIdentity.isValidStorageId(previousActiveStorageId), ) val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() - val prefix = "$KEY_PROFILE.$targetStorageId." val editor = preferences.edit() if (previousProfile == null) { ids.remove(targetStorageId) - editor.remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + editor.removeProfileKeys(targetStorageId) } else { ids += targetStorageId - editor.putString(prefix + CLIENT, previousProfile.client.name) - .putString(prefix + REGION, previousProfile.serverRegion.storedValue) - .putLong(prefix + PLATOON_ID, previousProfile.platoonId) - .putString(prefix + NAME, previousProfile.platoonName) - .putLong(prefix + BANNER_FRAME_ID, previousProfile.bannerFrameId) - .putLong(prefix + BANNER_MARK_ID, previousProfile.bannerMarkId) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .putLong(prefix + LAST_SEEN, previousProfile.lastSeenAt.toEpochMilli()) - .putBoolean(prefix + LEGACY, previousProfile.legacy) + editor.putProfile(previousProfile) } editor.putStringSet(KEY_IDS, ids) if (previousActiveStorageId != null && previousActiveStorageId in ids) { @@ -446,19 +393,9 @@ internal class PlatoonProfileRegistry(context: Context) { val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() if (!ids.remove(storageId)) return@synchronized false val remaining = ids.mapNotNull(::readLocked) - val prefix = "$KEY_PROFILE.$storageId." val editor = preferences.edit() .putStringSet(KEY_IDS, ids) - .remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + .removeProfileKeys(storageId) if (preferences.getString(KEY_ACTIVE, null) == storageId) { val fallback = remaining.maxByOrNull(PlatoonProfile::lastSeenAt) if (fallback == null) { @@ -481,19 +418,9 @@ internal class PlatoonProfileRegistry(context: Context) { if (!ids.remove(PlatoonProfileIdentity.LEGACY_STORAGE_ID) && preferences.getString(KEY_ACTIVE, null) != PlatoonProfileIdentity.LEGACY_STORAGE_ID ) return - val prefix = "$KEY_PROFILE.${PlatoonProfileIdentity.LEGACY_STORAGE_ID}." val editor = preferences.edit() .putStringSet(KEY_IDS, ids) - .remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + .removeProfileKeys(PlatoonProfileIdentity.LEGACY_STORAGE_ID) if (preferences.getString(KEY_ACTIVE, null) == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { editor.remove(KEY_ACTIVE) } @@ -502,7 +429,7 @@ internal class PlatoonProfileRegistry(context: Context) { private fun readLocked(storageId: String): PlatoonProfile? = runCatching { if (!PlatoonProfileIdentity.isValidStorageId(storageId)) return@runCatching null - val prefix = "$KEY_PROFILE.$storageId." + val prefix = profilePrefix(storageId) PlatoonProfile( storageId = storageId, client = PlatoonClient.valueOf(requireNotNull(preferences.getString(prefix + CLIENT, null))), @@ -519,23 +446,41 @@ internal class PlatoonProfileRegistry(context: Context) { private fun writeLocked(profile: PlatoonProfile, setActive: Boolean) { val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() ids += profile.storageId - val prefix = "$KEY_PROFILE.${profile.storageId}." val editor = preferences.edit() .putStringSet(KEY_IDS, ids) - .putString(prefix + CLIENT, profile.client.name) - .putString(prefix + REGION, profile.serverRegion.storedValue) - .putLong(prefix + PLATOON_ID, profile.platoonId) - .putString(prefix + NAME, profile.platoonName) - .putLong(prefix + BANNER_FRAME_ID, profile.bannerFrameId) - .putLong(prefix + BANNER_MARK_ID, profile.bannerMarkId) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .putLong(prefix + LAST_SEEN, profile.lastSeenAt.toEpochMilli()) - .putBoolean(prefix + LEGACY, profile.legacy) + .putProfile(profile) if (setActive) editor.putString(KEY_ACTIVE, profile.storageId) check(editor.commit()) { "Unable to persist the Platoon profile registry" } } + private fun SharedPreferences.Editor.putProfile(profile: PlatoonProfile) = apply { + val prefix = profilePrefix(profile.storageId) + putString(prefix + CLIENT, profile.client.name) + putString(prefix + REGION, profile.serverRegion.storedValue) + putLong(prefix + PLATOON_ID, profile.platoonId) + putString(prefix + NAME, profile.platoonName) + putLong(prefix + BANNER_FRAME_ID, profile.bannerFrameId) + putLong(prefix + BANNER_MARK_ID, profile.bannerMarkId) + remove(prefix + EMBLEM_PRIMARY) + remove(prefix + EMBLEM_SECONDARY) + putLong(prefix + LAST_SEEN, profile.lastSeenAt.toEpochMilli()) + putBoolean(prefix + LEGACY, profile.legacy) + } + + private fun SharedPreferences.Editor.removeProfileKeys(storageId: String) = apply { + val prefix = profilePrefix(storageId) + PROFILE_KEYS.forEach { key -> remove(prefix + key) } + } + + private fun profilePrefix(storageId: String) = "$KEY_PROFILE.$storageId." + + /** A new scope must fit under [MAX_PROFILES]; an existing scope is always replaceable. */ + private fun requireCapacityLocked(storageId: String) { + require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { + "Too many Platoon profiles are already registered" + } + } + private fun requireCompatibleRestoreTargetLocked(profile: PlatoonProfile) { require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } val profiles = readAllLocked() @@ -554,11 +499,7 @@ internal class PlatoonProfileRegistry(context: Context) { require( profiles.none { it.storageId != profile.storageId && - !it.legacy && - !profile.legacy && - it.client == profile.client && - it.serverRegion == profile.serverRegion && - it.platoonId == profile.platoonId + it.hasIdentity(profile.client, profile.serverRegion, profile.platoonId) }, ) { "Backup Platoon identity already belongs to another storage scope" } } @@ -578,7 +519,7 @@ internal class PlatoonProfileRegistry(context: Context) { } private fun normalizeName(value: String): String = value - .replace(Regex("\\s+"), " ") + .replace(WHITESPACE, " ") .filterNot(Char::isISOControl) .trim() .take(PlatoonProfile.MAX_NAME_LENGTH) @@ -620,6 +561,19 @@ internal class PlatoonProfileRegistry(context: Context) { private const val EMBLEM_SECONDARY = "emblem_secondary" private const val LAST_SEEN = "last_seen" private const val LEGACY = "legacy" + private val PROFILE_KEYS = listOf( + CLIENT, + REGION, + PLATOON_ID, + NAME, + BANNER_FRAME_ID, + BANNER_MARK_ID, + EMBLEM_PRIMARY, + EMBLEM_SECONDARY, + LAST_SEEN, + LEGACY, + ) + private val WHITESPACE = Regex("\\s+") private val lock = Any() } } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt index e716e6a..c610d5b 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt @@ -6,40 +6,44 @@ import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences +import java.io.File +import java.time.ZoneId /** Owns destructive and derived-data maintenance for isolated Platoon profiles. */ -internal class PlatoonProfileAdministration(context: Context) { +internal class PlatoonProfileAdministration internal constructor( + context: Context, + private val rebuildWeeklyHistory: (Context, PlatoonStorageScope, ZoneId) -> Unit, +) { + constructor(context: Context) : this(context, { appContext, scope, zone -> + PlatoonRepository(appContext, scope).rebuildWeeklyHistoryForTimeZoneChange(zone) + }) + private val appContext = context.applicationContext private val registry = PlatoonProfileRegistry(appContext) /** * Updates verified server metadata while retaining the profile's immutable storage scope. - * Weekly history is rebuilt with the new reset zone and rolled back if rebuilding fails. + * Weekly history is rebuilt with the new reset zone. If rebuilding fails, the registry entry + * and the exact scoped timezone keys, including a restored manual zone, are put back. */ fun changeServerRegion(storageId: String, region: GameServerRegion): PlatoonProfile { val before = requireNotNull(registry.find(storageId)) { "Unknown Platoon profile" } require(!before.legacy) if (before.serverRegion == region) return before - val wasAutomatic = GameTimeZonePreferences.isAutomatic(appContext, storageId) - val previousOverride = GameTimeZonePreferences.region(appContext, storageId) + val scope = PlatoonStorageScope(storageId) + val previousZoneState = GameTimeZonePreferences.scopedState(appContext, storageId) val previousZone = GameTimeZonePreferences.get(appContext, storageId) val updated = registry.updateServerRegion(storageId, region) return try { GameTimeZonePreferences.clearRegionOverride(appContext, storageId) - PlatoonRepository(appContext, PlatoonStorageScope(storageId)) - .rebuildWeeklyHistoryForTimeZoneChange(requireNotNull(region.serverZone)) + rebuildWeeklyHistory(appContext, scope, requireNotNull(region.serverZone)) updated } catch (error: Exception) { runCatching { registry.updateServerRegion(storageId, before.serverRegion) } .onFailure(error::addSuppressed) runCatching { - if (wasAutomatic) { - GameTimeZonePreferences.clearRegionOverride(appContext, storageId) - } else { - GameTimeZonePreferences.setRegion(appContext, previousOverride, storageId) - } - PlatoonRepository(appContext, PlatoonStorageScope(storageId)) - .rebuildWeeklyHistoryForTimeZoneChange(previousZone) + GameTimeZonePreferences.restoreScopedState(appContext, storageId, previousZoneState) + rebuildWeeklyHistory(appContext, scope, previousZone) }.onFailure(error::addSuppressed) throw error } @@ -82,6 +86,9 @@ internal class PlatoonProfileAdministration(context: Context) { private const val KEY_PENDING = "pending_storage_ids" fun recoverPending(context: Context) { + // A thread already reading a repository cannot take the exclusive gate; the + // ReentrantReadWriteLock cannot upgrade. The next registry access finishes the work. + if (PlatoonRepository.holdsSharedDatabaseAccess()) return val appContext = context.applicationContext pending(appContext).forEach { storageId -> if (PlatoonProfileIdentity.isValidStorageId(storageId) && @@ -105,7 +112,7 @@ internal class PlatoonProfileAdministration(context: Context) { } } val root = scope.rootDirectory(context).canonicalFile - val profilesRoot = java.io.File(context.filesDir, "platoons").canonicalFile + val profilesRoot = File(context.filesDir, "platoons").canonicalFile check(root.parentFile == profilesRoot) { "Refusing to delete outside the isolated Platoon directory" } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt index 39077f5..374fdb7 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt @@ -1,19 +1,25 @@ package dev.gf2log.app.management import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream import java.time.Instant -/** Durable rollback state for profile metadata changed by a scoped backup restore. */ +/** + * Durable rollback state for profile metadata changed by a scoped backup restore. The exact + * scoped timezone keys are kept because settings rollback can only replay effective values, + * which would turn an automatic reset region into an explicit override. + */ internal data class PlatoonProfileRestoreJournal( val targetStorageId: String, val previousProfile: PlatoonProfile?, val previousActiveStorageId: String?, val ownerPackage: String?, val previousCaptureRegion: GameServerRegion?, + val previousTimeZone: GameTimeZonePreferences.ScopedZoneState? = null, ) internal object PlatoonProfileRestoreJournalCodec { @@ -36,6 +42,11 @@ internal object PlatoonProfileRestoreJournalCodec { output.writeLong(profile.lastSeenAt.toEpochMilli()) output.writeBoolean(profile.legacy) } + output.writeBoolean(value.previousTimeZone != null) + value.previousTimeZone?.let { state -> + output.writeNullable(state.region) + output.writeNullable(state.zone) + } } bytes.toByteArray() } @@ -43,7 +54,7 @@ internal object PlatoonProfileRestoreJournalCodec { fun decode(bytes: ByteArray): PlatoonProfileRestoreJournal = DataInputStream(ByteArrayInputStream(bytes)).use { input -> val version = input.readInt() - require(version in setOf(LEGACY_VERSION, VERSION)) { + require(version in LEGACY_VERSION..VERSION) { "Unsupported profile restore journal" } val targetStorageId = input.readUTF() @@ -84,6 +95,16 @@ internal object PlatoonProfileRestoreJournalCodec { } else { null } + // The raw keys are rolled back verbatim: the timezone reader already tolerates an + // unknown region or zone, so rejecting one here would only abort recovery. + val previousTimeZone = if (version >= TIME_ZONE_VERSION && input.readBoolean()) { + GameTimeZonePreferences.ScopedZoneState( + region = input.readNullable(), + zone = input.readNullable(), + ) + } else { + null + } require(input.read() == -1) { "Profile restore journal contains trailing data" } PlatoonProfileRestoreJournal( targetStorageId, @@ -91,6 +112,7 @@ internal object PlatoonProfileRestoreJournalCodec { previousActive, ownerPackage, previousRegion, + previousTimeZone, ) } @@ -108,6 +130,7 @@ internal object PlatoonProfileRestoreJournalCodec { } private const val LEGACY_VERSION = 1 - private const val VERSION = 2 + private const val TIME_ZONE_VERSION = 3 + private const val VERSION = TIME_ZONE_VERSION private const val LEGACY_MAX_EMBLEM_PARTS = 32 } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt new file mode 100644 index 0000000..164327d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -0,0 +1,289 @@ +package dev.gf2log.app.management + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.PlatoonDatabase.Companion.DAILY_PATROL_REWARD_ACTION_ID +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Read-only projections served to the repository. */ +internal object PlatoonReadQueries { + fun latestSnapshotIdentity(db: SQLiteDatabase): Pair? = db.rawQuery( + "SELECT captured_at, source_file FROM snapshots " + + "ORDER BY captured_at DESC, source_file DESC, id DESC LIMIT 1", + null, + ).use { cursor -> + if (cursor.moveToFirst()) { + Instant.ofEpochMilli(cursor.getLong(0)) to cursor.getNullableString(1) + } else { + null + } + } + + /** The earliest roster captured strictly after [instant], if any. */ + fun firstSnapshotCapturedAfter(db: SQLiteDatabase, instant: Instant): Instant? = db.rawQuery( + "SELECT MIN(captured_at) FROM snapshots WHERE captured_at > ?", + arrayOf(instant.toEpochMilli().toString()), + ).use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) Instant.ofEpochMilli(cursor.getLong(0)) else null + } + + /** When the timed membership events observed at [observedAt] happened, newest last. */ + fun eventInstantsObservedAt(db: SQLiteDatabase, observedAt: Instant): List = db.rawQuery( + "SELECT DISTINCT COALESCE(occurred_at, observed_at) FROM member_events " + + "WHERE observed_at = ? AND event_date IS NULL ORDER BY 1", + arrayOf(observedAt.toEpochMilli().toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(Instant.ofEpochMilli(cursor.getLong(0))) + } + } + + fun snapshotSourceFiles(db: SQLiteDatabase): Set = db.rawQuery( + "SELECT source_file FROM snapshots WHERE source_file IS NOT NULL", + null, + ).use { cursor -> + buildSet { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + + fun listDailyPatrolFacts(db: SQLiteDatabase, from: Instant, until: Instant): List { + require(!until.isBefore(from)) + return db.query( + true, + "platoon_activity", + arrayOf("resolved_uid", "occurred_at"), + """ + action_id = ? AND resolved_uid IS NOT NULL + AND resolution = ? AND occurred_at >= ? AND occurred_at < ? + """.trimIndent(), + arrayOf( + DAILY_PATROL_REWARD_ACTION_ID.toString(), + ActivityResolution.EXACT_UPDATE.name, + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + ), + null, + null, + "occurred_at, resolved_uid", + null, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + DailyPatrolFact( + uid = cursor.getLong(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + ), + ) + } + } + } + } + + /** Two queries in total: the members, then every listed member's periods at once. */ + fun listMemberStatuses(db: SQLiteDatabase, activeOnly: Boolean): List { + val selection = if (activeOnly) "is_active = 1" else null + val periodsByUid = readMembershipPeriods(db, selection) + return db.query( + "members", + MEMBER_COLUMNS, + selection, + null, + null, + null, + "is_active DESC, COALESCE(custom_name, current_name) COLLATE NOCASE, uid", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + val uid = cursor.getLong(0) + add( + MemberStatus( + uid = uid, + name = cursor.getString(1), + level = cursor.getLong(2), + isActive = cursor.getInt(3) != 0, + firstSeenAt = Instant.ofEpochMilli(cursor.getLong(4)), + lastSeenAt = Instant.ofEpochMilli(cursor.getLong(5)), + note = cursor.getString(6), + membershipPeriods = periodsByUid[uid].orEmpty(), + ), + ) + } + } + } + } + + private fun readMembershipPeriods( + db: SQLiteDatabase, + memberSelection: String?, + ): Map> = db.query( + "membership_periods", + MEMBERSHIP_PERIOD_COLUMNS, + memberSelection?.let { "uid IN (SELECT uid FROM members WHERE $it)" }, + null, + null, + null, + "uid, CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", + ).use { cursor -> + val periods = mutableMapOf>() + while (cursor.moveToNext()) { + val uid = cursor.getLong(12) + periods.getOrPut(uid, ::mutableListOf) += MembershipPeriod( + id = cursor.getLong(0), + uid = uid, + joinedAt = cursor.getNullableInstant(1), + leftAt = cursor.getNullableInstant(2), + joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), + leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), + joinedTimeKnown = cursor.getInt(5) != 0, + leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 }, + joinedPrecision = enumValueOf(cursor.getString(7)), + leftPrecision = cursor.getNullableString(8)?.let(EvidencePrecision::valueOf), + joinedSource = enumValueOf(cursor.getString(9)), + leftSource = cursor.getNullableString(10)?.let(EvidenceSource::valueOf), + note = cursor.getString(11), + ) + } + periods + } + + fun listEvents( + db: SQLiteDatabase, + from: Instant, + until: Instant, + fromDate: LocalDate, + untilDate: LocalDate, + ): List { + require(!until.isBefore(from)) + require(!untilDate.isBefore(fromDate)) + return db.query( + "member_events", + EVENT_COLUMNS, + """ + ( + event_date IS NOT NULL + AND event_date >= CAST(? AS INTEGER) + AND event_date < CAST(? AS INTEGER) + ) + OR + ( + event_date IS NULL + AND COALESCE(occurred_at, observed_at) >= CAST(? AS INTEGER) + AND COALESCE(occurred_at, observed_at) < CAST(? AS INTEGER) + ) + """.trimIndent(), + arrayOf( + fromDate.toEpochDay().toString(), + untilDate.toEpochDay().toString(), + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + ), + null, + null, + "COALESCE(occurred_at, observed_at), id", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.toMemberEvent()) + } + } + } + + fun listWeeklyEvidenceDays(db: SQLiteDatabase, zoneId: ZoneId): List { + val days = mutableListOf() + listOf( + "snapshots" to "captured_at", + "platoon_activity" to "occurred_at", + ).forEach { (table, expression) -> + db.rawQuery("SELECT DISTINCT $expression FROM $table", null).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) { + days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) + } + } + } + } + db.rawQuery( + "SELECT DISTINCT COALESCE(occurred_at, observed_at) " + + "FROM member_events WHERE event_date IS NULL", + null, + ).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) { + days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) + } + } + } + listOf("weekly_notes", "weekly_overrides").forEach { table -> + db.rawQuery("SELECT DISTINCT period_start FROM $table", null).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) + } + } + } + db.rawQuery("SELECT DISTINCT event_date FROM member_events", null).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) + } + } + return days.distinct() + } + + fun membershipPeriodOwner(db: SQLiteDatabase, membershipPeriodId: Long): Long? = + db.rawQuery( + "SELECT uid FROM membership_periods WHERE id = ?", + arrayOf(membershipPeriodId.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + + private fun Cursor.toMemberEvent(): MemberEvent = MemberEvent( + id = getLong(0), + uid = getLong(1), + type = enumValueOf(getString(2)), + occurredAt = getNullableInstant(3), + eventDate = getNullableLong(4)?.let(LocalDate::ofEpochDay), + timeKnown = getInt(5) != 0, + observedAt = Instant.ofEpochMilli(getLong(6)), + precision = enumValueOf(getString(7)), + source = enumValueOf(getString(8)), + note = getString(9), + ) + + private val MEMBER_COLUMNS = arrayOf( + "uid", + "COALESCE(custom_name, current_name)", + "current_level", + "is_active", + "first_seen_at", + "last_seen_at", + "note", + ) + private val EVENT_COLUMNS = arrayOf( + "id", + "uid", + "event_type", + "occurred_at", + "event_date", + "time_known", + "observed_at", + "precision", + "source", + "note", + ) + private val MEMBERSHIP_PERIOD_COLUMNS = arrayOf( + "id", + "joined_at", + "left_at", + "joined_date", + "left_date", + "joined_time_known", + "left_time_known", + "joined_precision", + "left_precision", + "joined_source", + "left_source", + "note", + "uid", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt index affb54e..108258e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -26,6 +26,9 @@ internal class PlatoonRepository( PlatoonBackupManager.recoverInterruptedFullRestore(appContext, storageScope) } + /** The 05:00 reset zone of this repository's own Platoon, never the currently active one. */ + fun gameZone(): ZoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId) + fun ingest( capturedAt: Instant, members: List, @@ -45,10 +48,30 @@ internal class PlatoonRepository( source = EvidenceSource.SNAPSHOT, ) } - if (!result.duplicate) recordChangedWeeks(setOf(capturedAt)) + when { + result.duplicate -> Unit + // A replayed older roster can rebuild membership periods across every week. + result.historical -> recordAllLiveWeeklyReports() + else -> recordChangedWeeks(sequenceOf(capturedAt) + rosterBoundaryInstants(result, capturedAt)) + } return result } + /** + * A roster that joins or withdraws members can correlate each boundary to an exact activity + * time anywhere after the previous roster, possibly in an earlier week. Those events are + * observed at [capturedAt], so their times name every week the roster changed. + */ + private fun rosterBoundaryInstants(result: SnapshotIngestResult, capturedAt: Instant): List { + if (result.joined + result.rejoined + result.left == 0) return emptyList() + return try { + access { it.eventInstantsObservedAt(capturedAt) } + } catch (error: Exception) { + Log.e(TAG, "Unable to read roster boundary times; refreshing the roster's week", error) + emptyList() + } + } + fun ingestActivity( data: PlatoonActivityData, capturedAt: Instant = Instant.now(), @@ -177,20 +200,18 @@ internal class PlatoonRepository( fun listEvents( from: Instant, until: Instant, - fromDate: java.time.LocalDate, - untilDate: java.time.LocalDate, + fromDate: LocalDate, + untilDate: LocalDate, ): List = access { it.listEvents(from, until, fromDate, untilDate) } fun listDailyPatrolFacts(from: Instant, until: Instant): List = access { it.listDailyPatrolFacts(from, until) } fun updateMember(uid: Long, name: String, note: String): Boolean = - access { it.updateMember(uid, name, note) }.also { changed -> - if (changed) recordAllLiveWeeklyReports() - } + refreshingWeeksOf(uid) { access { it.updateMember(uid, name, note) } } - fun deleteMember(uid: Long): Boolean { - val deleted = withExclusiveDatabase(storageScope) { + fun deleteMember(uid: Long): Boolean = refreshingWeeksOf(uid) { + withExclusiveDatabase(storageScope) { val order = MemberOrderPreferences(appContext, storageScope.storageId) val previousOrder = order.read() val updatedOrder = previousOrder.filterNot { it == uid } @@ -216,8 +237,6 @@ internal class PlatoonRepository( throw error } } - if (deleted) recordAllLiveWeeklyReports() - return deleted } fun updateMembershipPeriod( @@ -225,12 +244,14 @@ internal class PlatoonRepository( joined: MembershipBoundaryValue, left: MembershipBoundaryValue?, note: String, - ): Boolean = access { it.updateMembershipPeriod(membershipPeriodId, joined, left, note) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + ): Boolean = refreshingWeeksOfPeriodOwner(membershipPeriodId) { + access { it.updateMembershipPeriod(membershipPeriodId, joined, left, note) } + } fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean = - access { it.deleteMembershipPeriod(membershipPeriodId) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + refreshingWeeksOfPeriodOwner(membershipPeriodId) { + access { it.deleteMembershipPeriod(membershipPeriodId) } + } fun addWithdrawnMember( uid: Long, @@ -238,16 +259,18 @@ internal class PlatoonRepository( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue, note: String, - ): Boolean = access { it.addWithdrawnMember(uid, name, joined, withdrew, note) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + ): Boolean = refreshingWeeksOf(uid) { + access { it.addWithdrawnMember(uid, name, joined, withdrew, note) } + } fun addMembershipPeriod( uid: Long, joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, note: String, - ): Boolean = access { it.addMembershipPeriod(uid, joined, withdrew, note) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + ): Boolean = refreshingWeeksOf(uid) { + access { it.addMembershipPeriod(uid, joined, withdrew, note) } + } fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long = access { it.addWeeklyNote(periodStartEpochDay, gameDayEpochDay, text) }.also { @@ -267,29 +290,53 @@ internal class PlatoonRepository( fun listWeeklyOverrides(periodStartEpochDay: Long): List = access { it.listWeeklyOverrides(periodStartEpochDay) } + /** The displayed report (a restored revision when selected) without writing history. */ fun buildWeeklyReport( referenceDay: LocalDate, zoneId: ZoneId, asOf: Instant = Instant.now(), - ): WeeklyReportBuilder.Report = buildWeeklyTableRevision(referenceDay, zoneId, asOf).report - + ): WeeklyReportBuilder.Report = readWeeklyTableRevision(referenceDay, zoneId, asOf).report + + /** + * The weekly screen's load: returns the displayed revision and records the live projection + * it was derived from, so the history list includes what the user saw. History is a derived + * audit trail, so a revision it cannot record still renders. A stored selection that no + * longer decodes is cleared and the live projection is shown instead. + */ fun buildWeeklyTableRevision( referenceDay: LocalDate, zoneId: ZoneId, asOf: Instant = Instant.now(), ): WeeklyTableRevision { val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) - recordHistory(live, Instant.now(), clearActiveOnChange = false) - val activePayload = access { + try { + recordHistory(live, Instant.now(), clearActiveOnChange = false) + } catch (error: Exception) { + Log.e(TAG, "Unable to record weekly history for ${live.report.periodStart}", error) + } + return activeRevision(live) ?: live.also { + access { database -> + database.clearActiveWeeklyReportHistory(live.report.periodStart.toEpochDay()) + } + } + } + + /** Like [buildWeeklyTableRevision] but strictly read-only. */ + fun readWeeklyTableRevision( + referenceDay: LocalDate, + zoneId: ZoneId, + asOf: Instant = Instant.now(), + ): WeeklyTableRevision { + val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) + return activeRevision(live) ?: live + } + + /** The selected historical revision, [live] when none is selected, or null if undecodable. */ + private fun activeRevision(live: WeeklyTableRevision): WeeklyTableRevision? { + val payload = access { it.activeWeeklyReportHistoryPayload(live.report.periodStart.toEpochDay()) } ?: return live - return runCatching { WeeklyReportHistoryCodec.decodeRevision(activePayload) } - .getOrElse { - access { database -> - database.clearActiveWeeklyReportHistory(live.report.periodStart.toEpochDay()) - } - live - } + return runCatching { WeeklyReportHistoryCodec.decodeRevision(payload) }.getOrNull() } private fun buildLiveWeeklyRevision( @@ -297,65 +344,91 @@ internal class PlatoonRepository( zoneId: ZoneId, asOf: Instant, ): WeeklyTableRevision { - val report = buildLiveWeeklyReport(referenceDay, zoneId, asOf) - val periodStart = report.periodStart - val statuses = listMemberStatuses() - val membershipEvents = listEvents( - periodStart.atStartOfDay(zoneId).toInstant(), - periodStart.plusDays(7).atStartOfDay(zoneId).toInstant(), - periodStart, - periodStart.plusDays(7), - ).filter { + val inputs = readRevisionInputs(PlatoonPeriods.weekStart(referenceDay), zoneId, asOf) + val report = WeeklyReportBuilder.build( + referenceDay = referenceDay, + zoneId = zoneId, + snapshots = inputs.snapshots, + membershipEvents = inputs.reportEvents, + overrides = inputs.overrides, + dailyPatrolFacts = inputs.dailyPatrolFacts, + asOf = asOf, + ) + val membershipEvents = inputs.revisionEvents.filter { it.type in MembershipEventPresentation.displayedTypes && it.source in MembershipEventPresentation.displayedSources }.take(MAX_REVISION_EVENTS) .map { it.copy(note = it.note.boundedUtf8(MAX_REVISION_NOTE_BYTES)) } - val relevantNameUids = ( - report.members.asSequence().map { it.uid } + membershipEvents.asSequence().map { it.uid } - ).toSet() + val reportUids = report.members.mapTo(mutableSetOf()) { it.uid } + val relevantNameUids = reportUids + membershipEvents.map { it.uid } return ManagementConsistencyAudit.requireValid(WeeklyTableRevision( report = report, membershipEvents = membershipEvents, - notes = listWeeklyNotes(periodStart.toEpochDay()) + notes = inputs.notes .filterNot(WeeklyNote::isAutomatic) .take(MAX_REVISION_NOTES) .map { it.copy(text = it.text.boundedUtf8(MAX_REVISION_NOTE_BYTES)) }, - memberNamesByUid = statuses.asSequence() + memberNamesByUid = inputs.statuses.asSequence() .filter { it.uid in relevantNameUids } .take(MAX_REVISION_NAMES) .associate { it.uid to it.name.boundedUtf8(MAX_REVISION_NAME_BYTES) }, - memberPrivateNotesByUid = statuses.asSequence() - .filter { status -> report.members.any { it.uid == status.uid } } + memberPrivateNotesByUid = inputs.statuses.asSequence() + .filter { it.uid in reportUids } .associate { it.uid to it.note.boundedUtf8(MAX_REVISION_PRIVATE_NOTE_BYTES) }, )) } - private fun buildLiveWeeklyReport( - referenceDay: LocalDate, + /** Every input of one weekly revision, read under a single consistent database state. */ + private fun readRevisionInputs( + periodStart: LocalDate, zoneId: ZoneId, asOf: Instant, - ): WeeklyReportBuilder.Report { - val periodStart = PlatoonPeriods.weekStart(referenceDay) + ): RevisionInputs = access { database -> + database.readConsistently { + val roster = readRosterInputs(database, periodStart, zoneId, asOf) + RevisionInputs( + snapshots = roster.snapshots, + reportEvents = roster.reportEvents, + revisionEvents = roster.revisionEvents, + overrides = database.listWeeklyOverrides(periodStart.toEpochDay()), + dailyPatrolFacts = database.listDailyPatrolFacts( + PlatoonPeriods.periodStartInstant(periodStart, zoneId), + PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId), + ), + notes = database.listWeeklyNotes(periodStart.toEpochDay()), + statuses = database.listMemberStatuses(), + ) + } + } + + private fun readRosterInputs( + database: PlatoonDatabase, + periodStart: LocalDate, + zoneId: ZoneId, + asOf: Instant, + ): RosterInputs { val from = PlatoonPeriods.periodStartInstant(periodStart, zoneId) val until = PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId) - val snapshots = listSnapshotsForPeriod(from, until) + val snapshots = database.listSnapshotsForPeriod(from, until) val membershipEventFrom = minOf( from, snapshots.minOfOrNull(PlatoonSnapshot::capturedAt) ?: Instant.EPOCH, ) - return WeeklyReportBuilder.build( - referenceDay = referenceDay, - zoneId = zoneId, + return RosterInputs( snapshots = snapshots, - membershipEvents = listEvents( + reportEvents = database.listEvents( membershipEventFrom, minOf(until, asOf.plusMillis(1)), PlatoonPeriods.gameDay(membershipEventFrom, zoneId), periodStart.plusDays(7), ), - overrides = listWeeklyOverrides(periodStart.toEpochDay()), - dailyPatrolFacts = listDailyPatrolFacts(from, until), - asOf = asOf, + // The Join / Withdraw section uses calendar-day bounds rather than 05:00 resets. + revisionEvents = database.listEvents( + periodStart.atStartOfDay(zoneId).toInstant(), + periodStart.plusDays(7).atStartOfDay(zoneId).toInstant(), + periodStart, + periodStart.plusDays(7), + ), ) } @@ -372,7 +445,7 @@ internal class PlatoonRepository( ) { access { it.replaceWeeklyOverrides(periodStartEpochDay, overrides) } val day = LocalDate.ofEpochDay(periodStartEpochDay) - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() recordLiveWeeklyRevisionSafely(day, zone) } @@ -412,35 +485,91 @@ internal class PlatoonRepository( private fun recordChangedWeeks(instants: Iterable) = recordChangedWeeks(instants.asSequence()) + /** + * Refreshes the weeks that evidence at [instants] can change, including neighbouring weeks + * that read it across a 05:00 reset (see [WeeklyHistoryWorkPolicy.affectedPeriodStarts]). + * If the neighbour scan fails, the instants' own game weeks are still refreshed. + */ private fun recordChangedWeeks(instants: Sequence) { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) - WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) - .forEach { periodStart -> - recordLiveWeeklyRevisionSafely(periodStart, zone) - } + val zone = gameZone() + val changed = instants.toList() + val weeks = try { + WeeklyHistoryWorkPolicy.affectedPeriodStarts( + instants = changed.asSequence(), + zoneId = zone, + listedPeriodStarts = weeklyPeriodStarts(zone), + nextSnapshotAfter = { instant -> access { it.firstSnapshotCapturedAfter(instant) } }, + ) + } catch (error: Exception) { + Log.e(TAG, "Unable to scope neighbouring weekly history; refreshing own weeks", error) + WeeklyHistoryWorkPolicy.affectedPeriodStarts(changed.asSequence(), zone, emptyList()) { null } + } + weeks.forEach { periodStart -> recordLiveWeeklyRevisionSafely(periodStart, zone) } } - // Function Name: recordAllLiveWeeklyReports - // Description: - // - Refreshes every derived weekly revision after a primary-data mutation. - // - Keeps committed member or packet data authoritative when optional history maintenance fails. - // Parameters: - // - failFast: Propagates failures for explicit maintenance operations such as timezone rebuilds. - private fun recordAllLiveWeeklyReports(failFast: Boolean = false) { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + /** Refreshes every derived weekly revision after a primary-data mutation. */ + private fun recordAllLiveWeeklyReports() { + val zone = gameZone() + weeklyPeriodStarts(zone).forEach { periodStart -> + recordLiveWeeklyRevisionSafely(periodStart, zone) + } + } + + private fun weeklyPeriodStarts(zone: ZoneId): List = WeeklyReportRange.periodStarts(access { it.listWeeklyEvidenceDays(zone) }) - .forEach { periodStart -> - if (failFast) { - val now = Instant.now() - recordHistory( - buildLiveWeeklyRevision(periodStart, zone, now), - now, - clearActiveOnChange = true, - ) - } else { - recordLiveWeeklyRevisionSafely(periodStart, zone) - } + + /** + * Runs a mutation confined to one member's rows and refreshes only the weeks whose revision + * can show that member before or after it: weeks whose period-end roster or Join / Withdraw + * section contains the member, or whose notes cite the member's events. Every other row, + * name, and note of a revision depends on other members' evidence alone. If the pre-scan + * fails, every week is refreshed so the mutation stays authoritative. + */ + private fun refreshingWeeksOf(uid: Long?, mutation: () -> Boolean): Boolean { + val zone = gameZone() + val asOf = Instant.now() + val before = uid?.let { runCatching { weeksShowingMember(it, zone, asOf) }.getOrNull() } + if (!mutation()) return false + try { + val weeks = weeklyPeriodStarts(zone) + val affected = if (uid == null || before == null) { + weeks + } else { + val current = weeks.toSet() + (before.filter { it in current } + weeksShowingMember(uid, zone, asOf)).distinct() + .sorted() } + affected.forEach { recordLiveWeeklyRevisionSafely(it, zone) } + } catch (error: Exception) { + Log.e(TAG, "Unable to scope weekly history refresh; refreshing every week", error) + recordAllLiveWeeklyReports() + } + return true + } + + private fun refreshingWeeksOfPeriodOwner( + membershipPeriodId: Long, + mutation: () -> Boolean, + ): Boolean = refreshingWeeksOf( + runCatching { access { it.membershipPeriodOwner(membershipPeriodId) } }.getOrNull(), + mutation, + ) + + private fun weeksShowingMember(uid: Long, zone: ZoneId, asOf: Instant): Set { + val citingNotes = access { it.weeklyNotePeriodsCitingMember(uid) } + return weeklyPeriodStarts(zone).filterTo(citingNotes.toMutableSet()) { periodStart -> + val inputs = access { database -> + database.readConsistently { readRosterInputs(database, periodStart, zone, asOf) } + } + inputs.revisionEvents.any { it.uid == uid } || + uid in WeeklyReportBuilder.rosterAtPeriodEnd( + periodStart, + zone, + inputs.snapshots, + inputs.reportEvents, + asOf, + ) + } } private fun recordHistory( @@ -461,17 +590,14 @@ internal class PlatoonRepository( } private fun recordLiveWeeklyRevision(periodStart: LocalDate) { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() recordLiveWeeklyRevisionSafely(periodStart, zone) } - // Function Name: recordLiveWeeklyRevisionSafely - // Description: - // - Records a derived table revision without changing the result of an already committed mutation. - // - Clears a restored projection on failure so the next screen render uses authoritative live data. - // Parameters: - // - periodStart: Sunday key of the weekly table to refresh. - // - zone: Persisted game timezone used by every date boundary in the projection. + /** + * Records a derived revision without changing the result of an already committed mutation. + * On failure the restored selection is cleared so the next render uses live data. + */ private fun recordLiveWeeklyRevisionSafely(periodStart: LocalDate, zone: ZoneId) { try { val now = Instant.now() @@ -517,18 +643,42 @@ internal class PlatoonRepository( val sourceFile: String?, ) + private class RosterInputs( + val snapshots: List, + val reportEvents: List, + val revisionEvents: List, + ) + + private class RevisionInputs( + val snapshots: List, + val reportEvents: List, + val revisionEvents: List, + val overrides: List, + val dailyPatrolFacts: List, + val notes: List, + val statuses: List, + ) + companion object { const val RETAINED_CSV_DIRECTORY = "guild-members" - private const val MAX_REVISION_EVENTS = 512 - private const val MAX_REVISION_NOTES = 128 - private const val MAX_REVISION_NAMES = 768 - private const val MAX_REVISION_NAME_BYTES = 1_024 - private const val MAX_REVISION_NOTE_BYTES = 16 * 1_024 - private const val MAX_REVISION_PRIVATE_NOTE_BYTES = 2 * 1_024 + private const val MAX_REVISION_EVENTS = WeeklyReportHistoryCodec.MAX_EVENTS + private const val MAX_REVISION_NOTES = WeeklyReportHistoryCodec.MAX_NOTES + private const val MAX_REVISION_NAMES = WeeklyReportHistoryCodec.MAX_CONTEXT_MEMBERS + private const val MAX_REVISION_NAME_BYTES = WeeklyReportHistoryCodec.MAX_STRING_BYTES + private const val MAX_REVISION_NOTE_BYTES = WeeklyReportHistoryCodec.MAX_NOTE_BYTES + private const val MAX_REVISION_PRIVATE_NOTE_BYTES = WeeklyReportHistoryCodec.MAX_PRIVATE_NOTE_BYTES private const val TAG = "GF2PlatoonRepository" + /* + * Global lock order; acquire left to right and never in reverse: + * CsvImportCheckpointManager scope state -> maintenanceLock -> PlatoonDatabase monitor -> + * PlatoonProfileRegistry lock -> settings preference locks. databaseLock only guards the + * helper cache; it is taken after the maintenance lock and only closes evicted helpers. + */ private val databaseLock = Any() private val maintenanceLock = ReentrantReadWriteLock(true) + internal fun holdsSharedDatabaseAccess(): Boolean = maintenanceLock.readHoldCount > 0 + private val databaseInstances = mutableMapOf() private fun database(context: Context, scope: PlatoonStorageScope): PlatoonDatabase = diff --git a/app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt b/app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt new file mode 100644 index 0000000..d274f4d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt @@ -0,0 +1,27 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences + +/** + * Keeps a restored profile's server region and its scoped reset-zone override consistent. A + * complete backup records only the effective reset region, so an override equal to the restored + * profile region is the archived automatic choice and is cleared instead of becoming explicit. A + * Platoon-only restore that changes the profile region drops the override like a server change + * in Settings does, so the old region's zone cannot outlive the region it was chosen for. + */ +internal object RestoredTimeZonePolicy { + fun clearsOverride( + state: GameTimeZonePreferences.ScopedZoneState, + previousRegion: GameServerRegion?, + restoredRegion: GameServerRegion, + restoredSettings: Boolean, + ): Boolean { + if (state.region == null && state.zone == null) return false + return if (restoredSettings) { + state.region != null && GameServerRegion.fromStored(state.region) == restoredRegion + } else { + previousRegion != restoredRegion + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt new file mode 100644 index 0000000..5d059d4 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt @@ -0,0 +1,445 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.MembershipRows.insertSnapshotBoundaryEvent +import dev.gf2log.app.management.MembershipRows.joinEventType +import dev.gf2log.app.management.MembershipRows.memberExists +import java.time.Instant + +/** + * Replays every retained roster chronologically after late historical imports. Only weak + * snapshot-derived periods are rebuilt; manual and exact evidence is preserved, and people seen + * only in historical rosters become inactive members. + */ +internal object RosterHistoryReplay { + fun reconcile(db: SQLiteDatabase) { + val snapshots = readRosterFrames(db) + if (snapshots.isEmpty()) return + + ensureHistoricalMembers(db, snapshots) + removeWeakSnapshotMembershipPeriods(db) + + val allUids = snapshots.flatMap { frame -> frame.members.keys }.toSortedSet() + allUids.forEach { uid -> + val runs = buildRosterPresenceRuns(snapshots, uid) + runs.forEach { run -> + if (!reconcilePreservedMembershipPeriodOverlap(db, uid, run)) { + insertRosterPresenceRun(db, uid, run) + } + } + } + MembershipRows.synchronizeAllActiveStates(db) + ActivityIngestion.resolveUnresolvedActivityUids(db) + } + + private fun readRosterFrames(db: SQLiteDatabase): List { + val frames = linkedMapOf>() + db.rawQuery( + """ + SELECT s.id, s.captured_at, sm.uid, sm.name, sm.level + FROM snapshots s + JOIN snapshot_members sm ON sm.snapshot_id = s.id + ORDER BY s.captured_at, s.id, sm.uid + """.trimIndent(), + null, + ).use { cursor -> + while (cursor.moveToNext()) { + val snapshotId = cursor.getLong(0) + val capturedAt = Instant.ofEpochMilli(cursor.getLong(1)) + frames.getOrPut(snapshotId) { linkedMapOf() }[cursor.getLong(2)] = + RosterIdentity(cursor.getString(3), cursor.getInt(4), capturedAt) + } + } + return frames.map { (_, members) -> + RosterFrame(members.values.first().capturedAt, members) + } + } + + private fun ensureHistoricalMembers(db: SQLiteDatabase, snapshots: List) { + snapshots.flatMap { frame -> + frame.members.map { (uid, identity) -> Triple(uid, identity, frame.capturedAt) } + }.groupBy { it.first }.forEach { (uid, observations) -> + val firstSeenAt = observations.minOf { it.third } + val latest = observations.maxBy { it.third } + if (!memberExists(db, uid)) { + MembershipRows.insertMember( + db = db, + uid = uid, + name = latest.second.name, + level = latest.second.level.toLong(), + firstSeenAt = firstSeenAt, + lastSeenAt = latest.third, + ) + } else { + db.execSQL( + "UPDATE members SET first_seen_at = MIN(first_seen_at, ?) WHERE uid = ?", + arrayOf(firstSeenAt.toEpochMilli(), uid), + ) + } + } + } + + private fun removeWeakSnapshotMembershipPeriods(db: SQLiteDatabase) { + val sourceNames = SNAPSHOT_EVENT_SOURCES.map(EvidenceSource::name) + val weakIds = db.rawQuery( + """ + SELECT id + FROM membership_periods + WHERE joined_source IN (?, ?) + AND (left_source IS NULL OR left_source IN (?, ?)) + AND joined_precision IN (?, ?) + AND (left_precision IS NULL OR left_precision = ?) + AND note = '' + """.trimIndent(), + arrayOf( + sourceNames[0], + sourceNames[1], + sourceNames[0], + sourceNames[1], + EvidencePrecision.UNKNOWN.name, + EvidencePrecision.INFERRED.name, + EvidencePrecision.INFERRED.name, + ), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } + + weakIds.forEach { membershipPeriodId -> + MembershipRows.deleteMembershipPeriod(db, membershipPeriodId) + } + } + + private fun buildRosterPresenceRuns( + snapshots: List, + uid: Long, + ): List { + val runs = mutableListOf() + var index = 0 + while (index < snapshots.size) { + if (uid !in snapshots[index].members) { + index += 1 + continue + } + val firstIndex = index + while (index + 1 < snapshots.size && uid in snapshots[index + 1].members) index += 1 + val lastIndex = index + val first = snapshots[firstIndex] + val last = snapshots[lastIndex] + runs += RosterPresenceRun( + firstSeenAt = first.capturedAt, + lastSeenAt = last.capturedAt, + joinedAt = first.capturedAt.takeIf { firstIndex > 0 }, + leftAt = snapshots.getOrNull(lastIndex + 1)?.capturedAt, + name = first.members.getValue(uid).name, + ) + index += 1 + } + return runs + } + + /** + * Preserves exact, manual, or user-annotated periods overlapping one roster-presence run. + * Every overlapping open period closes at an observed absence, so a later run becomes a + * separate inferred rejoin. Returns false when no preserved period covers the run. + */ + private fun reconcilePreservedMembershipPeriodOverlap( + db: SQLiteDatabase, + uid: Long, + run: RosterPresenceRun, + ): Boolean { + val overlaps = db.rawQuery( + """ + SELECT id, joined_at, joined_precision, joined_source, + left_at, left_precision, left_source + FROM membership_periods + WHERE uid = ? + AND (joined_at IS NULL OR joined_at <= ?) + AND (left_at IS NULL OR left_at > ?) + ORDER BY COALESCE(joined_at, -9223372036854775808), id + """.trimIndent(), + arrayOf( + uid.toString(), + run.lastSeenAt.toEpochMilli().toString(), + run.firstSeenAt.toEpochMilli().toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + PreservedMembershipPeriodOverlap( + id = cursor.getLong(0), + joinedAt = cursor.getNullableInstant(1), + joinedPrecision = EvidencePrecision.valueOf(cursor.getString(2)), + joinedSource = EvidenceSource.valueOf(cursor.getString(3)), + leftAt = cursor.getNullableInstant(4), + leftPrecision = cursor.getNullableString(5) + ?.let(EvidencePrecision::valueOf), + leftSource = cursor.getNullableString(6) + ?.let(EvidenceSource::valueOf), + ), + ) + } + } + } + overlaps.forEach { membershipPeriod -> + expandWeakJoinToRun(db, uid, membershipPeriod, run) + reconcileRunAbsence(db, uid, membershipPeriod, run) + } + return overlaps.isNotEmpty() + } + + /** Expands a weak inferred join to the earliest roster presence discovered later. */ + private fun expandWeakJoinToRun( + db: SQLiteDatabase, + uid: Long, + membershipPeriod: PreservedMembershipPeriodOverlap, + run: RosterPresenceRun, + ) { + val joinedAt = membershipPeriod.joinedAt + if ( + joinedAt != null && + run.firstSeenAt.isBefore(joinedAt) && + isWeakSnapshotBoundary( + membershipPeriod.joinedPrecision, + membershipPeriod.joinedSource, + ) + ) { + val updated = db.update( + "membership_periods", + ContentValues().apply { + put("joined_at", run.firstSeenAt.toEpochMilli()) + putNull("joined_date") + put("joined_time_known", 1) + put("joined_precision", EvidencePrecision.INFERRED.name) + put("joined_source", EvidenceSource.LEGACY_IMPORT.name) + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + if (updated == 1) { + updateSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriod.id, + uid = uid, + memberName = run.name, + type = joinEventType(db, uid, membershipPeriod.id), + observedAt = run.firstSeenAt, + ) + } + } + } + + /** Closes a preserved period at an observed roster absence, or reopens a weak closure. */ + private fun reconcileRunAbsence( + db: SQLiteDatabase, + uid: Long, + membershipPeriod: PreservedMembershipPeriodOverlap, + run: RosterPresenceRun, + ) { + val observedLeftAt = run.leftAt + val weakLeftBoundary = membershipPeriod.leftPrecision != null && + membershipPeriod.leftSource != null && + isWeakSnapshotBoundary( + membershipPeriod.leftPrecision, + membershipPeriod.leftSource, + ) + if ( + observedLeftAt != null && + ( + membershipPeriod.leftAt == null || + (weakLeftBoundary && membershipPeriod.leftAt != observedLeftAt) + ) + ) { + val updated = db.update( + "membership_periods", + ContentValues().apply { + put("left_at", observedLeftAt.toEpochMilli()) + putNull("left_date") + put("left_time_known", 1) + put("left_precision", EvidencePrecision.INFERRED.name) + put("left_source", EvidenceSource.LEGACY_IMPORT.name) + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + if (updated == 1) { + updateSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriod.id, + uid = uid, + memberName = run.name, + type = MemberEventType.LEFT, + observedAt = observedLeftAt, + ) + } + } else if ( + observedLeftAt == null && + membershipPeriod.leftAt != null && + weakLeftBoundary && + !run.lastSeenAt.isBefore(membershipPeriod.leftAt) + ) { + db.update( + "membership_periods", + ContentValues().apply { + putNull("left_at") + putNull("left_date") + putNull("left_time_known") + putNull("left_precision") + putNull("left_source") + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + deleteSnapshotBoundaryEvents( + db = db, + membershipPeriodId = membershipPeriod.id, + type = MemberEventType.LEFT, + ) + } + } + + private fun isWeakSnapshotBoundary( + precision: EvidencePrecision, + source: EvidenceSource, + ): Boolean = precision in setOf(EvidencePrecision.UNKNOWN, EvidencePrecision.INFERRED) && + source in SNAPSHOT_EVENT_SOURCES + + private fun updateSnapshotBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + observedAt: Instant, + ) { + val compatibleTypes = MembershipRows.compatibleEventTypes(type) + val updated = db.update( + "member_events", + ContentValues().apply { + put("event_type", type.name) + putNull("occurred_at") + putNull("event_date") + put("time_known", 1) + put("observed_at", observedAt.toEpochMilli()) + put("precision", EvidencePrecision.INFERRED.name) + put("source", EvidenceSource.LEGACY_IMPORT.name) + put("note", memberName) + }, + "membership_period_id = ? AND event_type IN (?, ?) " + + "AND source IN (?, ?)", + arrayOf( + membershipPeriodId.toString(), + compatibleTypes[0], + compatibleTypes[1], + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + ), + ) + if (updated == 0) { + insertSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + memberName = memberName, + type = type, + observedAt = observedAt, + source = EvidenceSource.LEGACY_IMPORT, + ) + } + } + + private fun deleteSnapshotBoundaryEvents( + db: SQLiteDatabase, + membershipPeriodId: Long, + type: MemberEventType, + ) { + MembershipRows.deleteEvents( + db, + "membership_period_id = ? AND event_type = ? AND source IN (?, ?)", + arrayOf( + membershipPeriodId.toString(), + type.name, + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + ), + ) + } + + private fun insertRosterPresenceRun( + db: SQLiteDatabase, + uid: Long, + run: RosterPresenceRun, + ) { + val membershipPeriodId = db.insertOrThrow( + "membership_periods", + null, + ContentValues().apply { + put("uid", uid) + putNullableLong("joined_at", run.joinedAt?.toEpochMilli()) + putNullableLong("left_at", run.leftAt?.toEpochMilli()) + put("joined_time_known", 1) + putNullableInt("left_time_known", run.leftAt?.let { 1 }) + put( + "joined_precision", + if (run.joinedAt == null) EvidencePrecision.UNKNOWN.name else EvidencePrecision.INFERRED.name, + ) + put( + "left_precision", + run.leftAt?.let { EvidencePrecision.INFERRED.name }, + ) + put("joined_source", EvidenceSource.LEGACY_IMPORT.name) + put("left_source", run.leftAt?.let { EvidenceSource.LEGACY_IMPORT.name }) + }, + ) + if (run.joinedAt != null) { + insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + run.name, + joinEventType(db, uid, membershipPeriodId), + run.joinedAt, + EvidenceSource.LEGACY_IMPORT, + ) + } + if (run.leftAt != null) { + insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + run.name, + MemberEventType.LEFT, + run.leftAt, + EvidenceSource.LEGACY_IMPORT, + ) + } + } + + private data class RosterIdentity( + val name: String, + val level: Int, + val capturedAt: Instant, + ) + + private data class RosterFrame( + val capturedAt: Instant, + val members: Map, + ) + + private data class PreservedMembershipPeriodOverlap( + val id: Long, + val joinedAt: Instant?, + val joinedPrecision: EvidencePrecision, + val joinedSource: EvidenceSource, + val leftAt: Instant?, + val leftPrecision: EvidencePrecision?, + val leftSource: EvidenceSource?, + ) + + private data class RosterPresenceRun( + val firstSeenAt: Instant, + val lastSeenAt: Instant, + val joinedAt: Instant?, + val leftAt: Instant?, + val name: String, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt index a0d04ac..8087614 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt @@ -32,9 +32,9 @@ object SnapshotComparison { MeritChange( uid = current.uid, name = current.name, - weeklyMeritDelta = counterDelta(current.weeklyMerit, previous.weeklyMerit), - totalMeritDelta = counterDelta(current.totalMerit, previous.totalMerit), - totalScoreDelta = counterDelta(current.totalScore, previous.totalScore), + weeklyMeritDelta = ActivityInference.counterDelta(previous.weeklyMerit, current.weeklyMerit), + totalMeritDelta = ActivityInference.counterDelta(previous.totalMerit, current.totalMerit), + totalScoreDelta = ActivityInference.counterDelta(previous.totalScore, current.totalScore), lastLoginChanged = current.lastLogin != previous.lastLogin, ) }.filter { @@ -45,7 +45,4 @@ object SnapshotComparison { }.sortedByDescending(MeritChange::weeklyMeritDelta), ) } - - private fun counterDelta(current: Long, previous: Long): Long = - if (current >= previous) current - previous else current } diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt new file mode 100644 index 0000000..e2b4c55 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -0,0 +1,353 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.ActivityIngestion.correlateMembershipBoundary +import dev.gf2log.app.management.ExactBoundaryCorrelation.findRosterConfirmedExactMembershipPeriod +import dev.gf2log.app.management.MembershipRows.closeLatestMembershipPeriod +import dev.gf2log.app.management.MembershipRows.ensureRosterActiveMembershipPeriod +import dev.gf2log.app.management.MembershipRows.insertEvent +import dev.gf2log.app.management.MembershipRows.insertMembershipPeriod +import dev.gf2log.app.management.MembershipRows.insertSnapshotBoundaryEvent +import dev.gf2log.protocol.GuildMembersCsv +import java.time.Instant +import java.util.Locale + +/** Ingests one 21917 roster snapshot and derives roster-change membership boundaries. */ +internal object SnapshotIngestion { + fun ingest( + db: SQLiteDatabase, + snapshot: PlatoonSnapshot, + source: EvidenceSource, + historicalOnly: Boolean, + deferHistoricalMembershipReconciliation: Boolean, + ): SnapshotIngestResult { + require(snapshot.members.isNotEmpty()) { "A Platoon snapshot cannot be empty" } + require(snapshot.members.size <= GuildMembersCsv.MAX_ROSTER_MEMBERS) { + "A Platoon snapshot exceeds the member limit" + } + require(snapshot.members.all { it.name.length <= GuildMembersCsv.MAX_MEMBER_NAME_CHARS }) { + "A Platoon snapshot contains an oversized member name" + } + require(snapshot.members.map(SnapshotMember::uid).distinct().size == snapshot.members.size) { + "A Platoon snapshot cannot contain duplicate UIDs" + } + return db.inTransaction { + if (snapshot.sourceFile != null && sourceFileExists(db, snapshot.sourceFile)) { + return@inTransaction SnapshotIngestResult.duplicate() + } + val priorCapturedAt = latestSnapshotCapturedAt(db) + // A roster older than the stored current one cannot describe current presence (a + // late packet or a clock moved backwards), so it is replayed chronologically. + val historical = historicalOnly || + priorCapturedAt?.let(snapshot.capturedAt::isBefore) == true + if (historical) { + require(priorCapturedAt != null && !snapshot.capturedAt.isAfter(priorCapturedAt)) { + "Historical snapshots must not be newer than current structured data" + } + val snapshotId = insertSnapshotRows(db, snapshot) + if (!deferHistoricalMembershipReconciliation) RosterHistoryReplay.reconcile(db) + SnapshotIngestResult.historical(snapshotId) + } else { + ingestCurrent(db, snapshot, source, priorCapturedAt) + } + } + } + + private fun ingestCurrent( + db: SQLiteDatabase, + snapshot: PlatoonSnapshot, + source: EvidenceSource, + priorCapturedAt: Instant?, + ): SnapshotIngestResult { + val known = readKnownMembers(db) + val changes = SnapshotReconciler.reconcile( + known = known, + incoming = snapshot.members, + hasPriorSnapshot = priorCapturedAt != null, + ) + val snapshotId = insertSnapshotRows(db, snapshot) + snapshot.members.forEach { member -> upsertMember(db, member, snapshot.capturedAt) } + if (priorCapturedAt != null) { + recordRosterChanges(db, snapshot, source, priorCapturedAt, known, changes) + } + // A roster is authoritative for current presence. Repair any missing open + // membership period left by an incomplete historical Updates feed. + snapshot.members.forEach { member -> + ensureRosterActiveMembershipPeriod(db, member.uid, source) + } + MembershipRows.synchronizeActiveState( + db, + snapshot.members.map(SnapshotMember::uid) + changes.left.map { it.uid }, + ) + ActivityIngestion.trimPlatoonActivity(db) + ActivityIngestion.resolveUnresolvedActivityUids(db) + return SnapshotIngestResult( + snapshotId = snapshotId, + duplicate = false, + initialRoster = changes.initialRoster, + joined = changes.joined.size, + rejoined = changes.rejoined.size, + left = changes.left.size, + renamed = changes.renamed.size, + ) + } + + private fun recordRosterChanges( + db: SQLiteDatabase, + snapshot: PlatoonSnapshot, + source: EvidenceSource, + priorCapturedAt: Instant, + known: List, + changes: SnapshotReconciler.Result, + ) { + val changedNameCounts = ( + changes.joined.map(SnapshotMember::name) + + changes.rejoined.map(SnapshotMember::name) + + changes.left.map(SnapshotReconciler.KnownMember::name) + ).groupingBy { it.lowercase(Locale.ROOT) }.eachCount() + val rosterNameUidCounts = ( + known.map { it.uid to it.name } + + snapshot.members.map { it.uid to it.name } + ).groupBy { it.second.lowercase(Locale.ROOT) } + .mapValues { (_, identities) -> identities.map { it.first }.distinct().size } + + // Activity names can be correlated only when neither the change set nor the roster + // contains another identity with the same case-insensitive name. + fun nameIsUnique(name: String): Boolean = name.lowercase(Locale.ROOT).let { key -> + changedNameCounts[key] == 1 && rosterNameUidCounts[key] == 1 + } + + changes.joined.forEach { member -> + recordSnapshotJoin( + db = db, + member = member, + type = MemberEventType.JOINED, + observedAt = snapshot.capturedAt, + priorCapturedAt = priorCapturedAt, + source = source, + nameIsUnique = nameIsUnique(member.name), + ) + } + changes.rejoined.forEach { member -> + recordSnapshotJoin( + db = db, + member = member, + type = MemberEventType.REJOINED, + observedAt = snapshot.capturedAt, + priorCapturedAt = priorCapturedAt, + source = source, + nameIsUnique = nameIsUnique(member.name), + ) + } + changes.left.forEach { member -> + recordSnapshotLeave( + db = db, + member = member, + observedAt = snapshot.capturedAt, + priorCapturedAt = priorCapturedAt, + source = source, + nameIsUnique = nameIsUnique(member.name), + ) + } + changes.renamed.forEach { rename -> + insertEvent( + db = db, + uid = rename.uid, + type = MemberEventType.RENAMED, + occurredAt = snapshot.capturedAt, + observedAt = snapshot.capturedAt, + precision = EvidencePrecision.INFERRED, + source = source, + note = "${rename.oldName} -> ${rename.newName}", + ) + } + } + + private fun recordSnapshotJoin( + db: SQLiteDatabase, + member: SnapshotMember, + type: MemberEventType, + observedAt: Instant, + priorCapturedAt: Instant, + source: EvidenceSource, + nameIsUnique: Boolean, + ) { + val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( + db = db, + uid = member.uid, + boundary = MembershipBoundary.JOIN, + from = priorCapturedAt, + observedAt = observedAt, + ) + if (exactMembershipPeriodId != null) return + + val membershipPeriodId = insertMembershipPeriod( + db = db, + uid = member.uid, + joinedAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + ) + insertSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = type, + observedAt = observedAt, + source = source, + ) + correlateMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = type, + boundary = MembershipBoundary.JOIN, + observedAt = observedAt, + from = priorCapturedAt, + nameIsUnique = nameIsUnique, + ) + } + + private fun recordSnapshotLeave( + db: SQLiteDatabase, + member: SnapshotReconciler.KnownMember, + observedAt: Instant, + priorCapturedAt: Instant, + source: EvidenceSource, + nameIsUnique: Boolean, + ) { + val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( + db = db, + uid = member.uid, + boundary = MembershipBoundary.WITHDRAW, + from = priorCapturedAt, + observedAt = observedAt, + ) + val membershipPeriodId = exactMembershipPeriodId ?: closeLatestMembershipPeriod( + db = db, + uid = member.uid, + leftAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + ) + recordLastSeen(db, member.uid, observedAt) + if (exactMembershipPeriodId != null) return + insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + member.uid, + member.name, + MemberEventType.LEFT, + observedAt, + source, + ) + correlateMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = MemberEventType.LEFT, + boundary = MembershipBoundary.WITHDRAW, + observedAt = observedAt, + from = priorCapturedAt, + nameIsUnique = nameIsUnique, + ) + } + + fun insertSnapshotRows(db: SQLiteDatabase, snapshot: PlatoonSnapshot): Long { + val snapshotId = db.insertOrThrow( + "snapshots", + null, + ContentValues().apply { + put("captured_at", snapshot.capturedAt.toEpochMilli()) + put("source_file", snapshot.sourceFile) + put("game_version", snapshot.gameVersion) + }, + ) + snapshot.members.forEach { member -> insertSnapshotMember(db, snapshotId, member) } + return snapshotId + } + + private fun insertSnapshotMember( + db: SQLiteDatabase, + snapshotId: Long, + member: SnapshotMember, + ) { + db.insertOrThrow( + "snapshot_members", + null, + ContentValues().apply { + put("snapshot_id", snapshotId) + put("uid", member.uid) + put("name", member.name) + put("level", member.level) + put("weekly_merit", member.weeklyMerit) + put("total_merit", member.totalMerit) + put("high_score", member.highScore) + put("total_score", member.totalScore) + put("last_login", member.lastLogin) + }, + ) + } + + private fun upsertMember(db: SQLiteDatabase, member: SnapshotMember, observedAt: Instant) { + val existing = ContentValues().apply { + put("current_name", member.name) + put("current_level", member.level) + put("last_seen_at", observedAt.toEpochMilli()) + } + if (db.update("members", existing, "uid = ?", arrayOf(member.uid.toString())) == 0) { + MembershipRows.insertMember( + db = db, + uid = member.uid, + name = member.name, + level = member.level, + firstSeenAt = observedAt, + lastSeenAt = observedAt, + ) + } + } + + private fun recordLastSeen(db: SQLiteDatabase, uid: Long, observedAt: Instant) { + db.update( + "members", + ContentValues().apply { put("last_seen_at", observedAt.toEpochMilli()) }, + "uid = ?", + arrayOf(uid.toString()), + ) + } + + private fun readKnownMembers(db: SQLiteDatabase): List = + db.rawQuery( + """ + SELECT m.uid, m.current_name, m.is_active, + EXISTS(SELECT 1 FROM membership_periods period WHERE period.uid = m.uid) + FROM members m + """.trimIndent(), + null, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + SnapshotReconciler.KnownMember( + uid = cursor.getLong(0), + name = cursor.getString(1), + isActive = cursor.getInt(2) != 0, + hasPriorMembershipPeriod = cursor.getInt(3) != 0, + ), + ) + } + } + } + + private fun sourceFileExists(db: SQLiteDatabase, sourceFile: String): Boolean = + db.exists("SELECT 1 FROM snapshots WHERE source_file = ? LIMIT 1", sourceFile) + + fun latestSnapshotCapturedAt(db: SQLiteDatabase): Instant? = + db.rawQuery("SELECT MAX(captured_at) FROM snapshots", null).use { cursor -> + if (cursor.moveToFirst()) cursor.getNullableInstant(0) else null + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt index 9412a97..93622d5 100644 --- a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt @@ -251,21 +251,14 @@ object StandardWeekSolver { val maximumPatrol = maximumPatrolTotals.maxOrNull() ?: return null return WeeklyReportBuilder.ResolvedStandardTotals( merit = minimumMerit, - meritCertainty = certainty(minimumMerit, maximumMerit), + meritCertainty = rangeCertainty(minimumMerit, maximumMerit), loginDays = minimumLogin, - loginDaysCertainty = certainty(minimumLogin, maximumLogin), + loginDaysCertainty = rangeCertainty(minimumLogin, maximumLogin), patrolDays = minimumPatrol, - patrolDaysCertainty = certainty(minimumPatrol, maximumPatrol), + patrolDaysCertainty = rangeCertainty(minimumPatrol, maximumPatrol), ) } - private fun certainty(minimum: Number, maximum: Number): MetricCertainty = - if (minimum.toLong() == maximum.toLong()) { - MetricCertainty.EXACT - } else { - MetricCertainty.LOWER_BOUND - } - private fun compatibleAllocations( indexes: List, days: List, @@ -301,8 +294,7 @@ object StandardWeekSolver { observationIndex >= 0 && observationIndex <= index } .all { observation -> - val observationIndex = days.indexOf(observation.gameDay) - observationIndex > index || checkpointStage( + checkpointStage( candidate, indexes.take(candidate.size), days, @@ -341,9 +333,6 @@ object StandardWeekSolver { return stage.takeIf { loginStageCompatible } } - private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = - members.firstOrNull { it.uid == uid } - private data class CounterObservation( val capturedAt: Instant, val gameDay: LocalDate, @@ -351,9 +340,9 @@ object StandardWeekSolver { val lastLoginDay: LocalDate?, ) - private const val MAX_DAILY_MERIT = 90L - private const val LOGIN_MERIT = 50L - private const val PATROL_MERIT = 90L - private val DAILY_MERIT = longArrayOf(90L, 50L, 0L) - private val DAILY_STAGE = setOf(0L, 50L, 90L) + private const val MAX_DAILY_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT + private const val LOGIN_MERIT = ActivityInference.LOGIN_MERIT + private const val PATROL_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT + private val DAILY_MERIT = longArrayOf(PATROL_MERIT, LOGIN_MERIT, 0L) + private val DAILY_STAGE = setOf(0L, LOGIN_MERIT, PATROL_MERIT) } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt new file mode 100644 index 0000000..215793c --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt @@ -0,0 +1,183 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import java.time.LocalDate + +/** Weekly notes and manual cell overrides. */ +internal object WeeklyAnnotationStore { + fun addWeeklyNote( + db: SQLiteDatabase, + periodStartEpochDay: Long, + gameDayEpochDay: Long, + text: String, + ): Long { + val storedText = ManualTextPolicy.note(text) + require(storedText.isNotEmpty()) + require( + WeeklyNotePolicy.isWithinWeek( + LocalDate.ofEpochDay(periodStartEpochDay), + LocalDate.ofEpochDay(gameDayEpochDay), + ), + ) { "A weekly note must belong to its week" } + val manualNoteCount = db.rawQuery( + "SELECT COUNT(*) FROM weekly_notes WHERE period_start = ? AND is_automatic = 0", + arrayOf(periodStartEpochDay.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getInt(0) + } + if (!WeeklyNotePolicy.canAdd(manualNoteCount)) throw WeeklyNoteLimitException() + return db.insertOrThrow( + "weekly_notes", + null, + ContentValues().apply { + put("period_start", periodStartEpochDay) + put("game_day", gameDayEpochDay) + put("text", storedText) + putNull("event_id") + put("is_automatic", 0) + }, + ) + } + + fun listWeeklyNotes(db: SQLiteDatabase, periodStartEpochDay: Long): List = + db.query( + "weekly_notes", + WEEKLY_NOTE_COLUMNS, + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + null, + null, + "game_day, id", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + WeeklyNote( + id = cursor.getLong(0), + periodStart = LocalDate.ofEpochDay(cursor.getLong(1)), + gameDay = LocalDate.ofEpochDay(cursor.getLong(2)), + text = cursor.getString(3), + eventId = cursor.getNullableLong(4), + isAutomatic = cursor.getInt(5) != 0, + ), + ) + } + } + } + + fun weeklyNotePeriodStart(db: SQLiteDatabase, id: Long): LocalDate? = db.rawQuery( + "SELECT period_start FROM weekly_notes WHERE id = ? AND is_automatic = 0", + arrayOf(id.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null + } + + fun notePeriodsCitingMember(db: SQLiteDatabase, uid: Long): Set = db.rawQuery( + "SELECT DISTINCT period_start FROM weekly_notes WHERE is_automatic = 0 AND event_id IN " + + "(SELECT id FROM member_events WHERE uid = ?)", + arrayOf(uid.toString()), + ).use { cursor -> + buildSet { while (cursor.moveToNext()) add(LocalDate.ofEpochDay(cursor.getLong(0))) } + } + + fun deleteWeeklyNote(db: SQLiteDatabase, id: Long): Boolean = + db.delete( + "weekly_notes", + "id = ? AND is_automatic = 0", + arrayOf(id.toString()), + ) == 1 + + fun listWeeklyOverrides(db: SQLiteDatabase, periodStartEpochDay: Long): List = + db.query( + "weekly_overrides", + WEEKLY_OVERRIDE_COLUMNS, + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + null, + null, + "game_day, uid", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + WeeklyCellOverride( + uid = cursor.getLong(0), + periodStart = LocalDate.ofEpochDay(cursor.getLong(1)), + gameDay = LocalDate.ofEpochDay(cursor.getLong(2)), + meritDelta = cursor.getNullableLong(3), + scoreDelta = cursor.getNullableLong(4), + attempts = cursor.getNullableInt(5), + attended = cursor.getNullableBoolean(6), + dailyPatrol = cursor.getNullableBoolean(7), + ), + ) + } + } + } + + fun replaceWeeklyOverrides( + db: SQLiteDatabase, + periodStartEpochDay: Long, + overrides: List, + ) { + require( + overrides.all { + it.periodStart.toEpochDay() == periodStartEpochDay && + WeeklyNotePolicy.isWithinWeek(it.periodStart, it.gameDay) + }, + ) { "A weekly override must belong to its week" } + require(overrides.distinctBy { it.uid to it.gameDay }.size == overrides.size) { + "A weekly cell can have only one override" + } + require( + overrides.all { + it.attempts == null || it.attempts in 0..ActivityInference.MAX_DAILY_ATTEMPTS + }, + ) + require(overrides.none { it.attended == false && it.dailyPatrol == true }) + db.inTransaction { + db.delete( + "weekly_overrides", + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + ) + overrides.forEach { override -> + db.insertOrThrow( + "weekly_overrides", + null, + ContentValues().apply { + put("uid", override.uid) + put("period_start", periodStartEpochDay) + put("game_day", override.gameDay.toEpochDay()) + putNullableLong("merit_delta", override.meritDelta) + putNullableLong("score_delta", override.scoreDelta) + putNullableInt("attempts", override.attempts) + putNullableBoolean("attended", override.attended) + putNullableBoolean("daily_patrol", override.dailyPatrol) + }, + ) + } + } + } + + private val WEEKLY_NOTE_COLUMNS = arrayOf( + "id", + "period_start", + "game_day", + "text", + "event_id", + "is_automatic", + ) + private val WEEKLY_OVERRIDE_COLUMNS = arrayOf( + "uid", + "period_start", + "game_day", + "merit_delta", + "score_delta", + "attempts", + "attended", + "daily_patrol", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt index 27aea9f..a41ad02 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt @@ -127,8 +127,8 @@ object WeeklyEvidenceAnalyzer { val certainties = cells.flatMap { cell -> buildList { add(cell.meritCertainty) - add(cell.attended.toCertainty(cell.observed)) - add(cell.dailyPatrol.toCertainty(cell.observed)) + add(cell.attended.toCertainty()) + add(cell.dailyPatrol.toCertainty()) if (report.isGunsmokeWeek) { add(cell.scoreCertainty) add(cell.attemptsCertainty) @@ -162,8 +162,8 @@ object WeeklyEvidenceAnalyzer { Metric.MERIT -> cell.meritCertainty Metric.SCORE -> cell.scoreCertainty Metric.ATTEMPTS -> cell.attemptsCertainty - Metric.LOGIN -> cell.attended.toCertainty(cell.observed) - Metric.DAILY_PATROL -> cell.dailyPatrol.toCertainty(cell.observed) + Metric.LOGIN -> cell.attended.toCertainty() + Metric.DAILY_PATROL -> cell.dailyPatrol.toCertainty() } private fun totalCertainty( @@ -193,9 +193,6 @@ object WeeklyEvidenceAnalyzer { Metric.DAILY_PATROL -> dailyPatrol != null } - private fun Boolean?.toCertainty(observed: Boolean): MetricCertainty = when { - this != null -> MetricCertainty.EXACT - observed -> MetricCertainty.UNKNOWN - else -> MetricCertainty.UNKNOWN - } + private fun Boolean?.toCertainty(): MetricCertainty = + if (this != null) MetricCertainty.EXACT else MetricCertainty.UNKNOWN } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt new file mode 100644 index 0000000..c2cc65f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt @@ -0,0 +1,180 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.PlatoonDatabase.Companion.MAX_WEEKLY_REPORT_HISTORY +import java.time.Instant +import java.time.LocalDate + +internal data class WeeklyReportHistoryReplacement( + val periodStartEpochDay: Long, + val recordedAt: Instant, + val fingerprint: String, + val payload: ByteArray, +) + +/** Derived weekly-table revisions and the restored-selection state. */ +internal object WeeklyHistoryStore { + /** + * Appends one changed immutable weekly projection and retains the newest + * [MAX_WEEKLY_REPORT_HISTORY] revisions. A restored selection is cleared only when + * [clearActiveOnChange] and the live fingerprint changed. Returns the existing or new ID. + */ + fun record( + db: SQLiteDatabase, + periodStartEpochDay: Long, + recordedAt: Instant, + fingerprint: String, + payload: ByteArray, + clearActiveOnChange: Boolean, + ): Long { + requireValidRevision(fingerprint, payload) + return db.inTransaction { + val latest = db.rawQuery( + "SELECT id, fingerprint FROM weekly_report_history " + + "WHERE period_start = ? ORDER BY recorded_at DESC, id DESC LIMIT 1", + arrayOf(periodStartEpochDay.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getLong(0) to cursor.getString(1) else null + } + if (latest?.second == fingerprint) return@inTransaction latest.first + val id = db.insertOrThrow( + "weekly_report_history", + null, + historyRow(periodStartEpochDay, recordedAt, fingerprint, payload), + ) + if (clearActiveOnChange) { + db.delete( + "weekly_report_history_state", + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + ) + } + db.delete( + "weekly_report_history", + "period_start = ? AND id NOT IN (" + + "SELECT id FROM weekly_report_history WHERE period_start = ? " + + "ORDER BY recorded_at DESC, id DESC LIMIT ?)", + arrayOf( + periodStartEpochDay.toString(), + periodStartEpochDay.toString(), + MAX_WEEKLY_REPORT_HISTORY.toString(), + ), + ) + id + } + } + + fun list(db: SQLiteDatabase, periodStartEpochDay: Long): List = + db.rawQuery( + "SELECT history.id, history.recorded_at, " + + "CASE WHEN state.active_history_id = history.id THEN 1 ELSE 0 END " + + "FROM weekly_report_history history " + + "LEFT JOIN weekly_report_history_state state " + + "ON state.period_start = history.period_start " + + "WHERE history.period_start = ? " + + "ORDER BY history.recorded_at DESC, history.id DESC LIMIT ?", + arrayOf(periodStartEpochDay.toString(), MAX_WEEKLY_REPORT_HISTORY.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + WeeklyReportHistoryEntry( + id = cursor.getLong(0), + periodStart = LocalDate.ofEpochDay(periodStartEpochDay), + recordedAt = Instant.ofEpochMilli(cursor.getLong(1)), + active = cursor.getInt(2) != 0, + ), + ) + } + } + } + + fun payload(db: SQLiteDatabase, id: Long, periodStartEpochDay: Long): ByteArray? = + db.rawQuery( + "SELECT report_blob FROM weekly_report_history WHERE id = ? AND period_start = ?", + arrayOf(id.toString(), periodStartEpochDay.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + + fun activePayload(db: SQLiteDatabase, periodStartEpochDay: Long): ByteArray? = + db.rawQuery( + "SELECT history.report_blob FROM weekly_report_history_state state " + + "JOIN weekly_report_history history ON history.id = state.active_history_id " + + "WHERE state.period_start = ? AND history.period_start = state.period_start", + arrayOf(periodStartEpochDay.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + + fun activate(db: SQLiteDatabase, id: Long, periodStartEpochDay: Long): Boolean { + val exists = db.exists( + "SELECT 1 FROM weekly_report_history WHERE id = ? AND period_start = ?", + id, + periodStartEpochDay, + ) + if (!exists) return false + db.insertWithOnConflict( + "weekly_report_history_state", + null, + ContentValues().apply { + put("period_start", periodStartEpochDay) + put("active_history_id", id) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + return true + } + + fun clearActive(db: SQLiteDatabase, periodStartEpochDay: Long): Boolean = + db.delete( + "weekly_report_history_state", + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + ) > 0 + + /** + * Replaces every derived weekly revision in one SQLite transaction, validating the complete + * replacement before deleting existing history. + */ + fun replace(db: SQLiteDatabase, replacements: List) { + require(replacements.map { it.periodStartEpochDay }.distinct().size == replacements.size) + replacements.forEach { requireValidRevision(it.fingerprint, it.payload) } + db.inTransaction { + deleteAll(db) + replacements.forEach { replacement -> + db.insertOrThrow( + "weekly_report_history", + null, + historyRow( + replacement.periodStartEpochDay, + replacement.recordedAt, + replacement.fingerprint, + replacement.payload, + ), + ) + } + } + } + + private fun deleteAll(db: SQLiteDatabase) { + db.delete("weekly_report_history_state", null, null) + db.delete("weekly_report_history", null, null) + } + + private fun historyRow( + periodStartEpochDay: Long, + recordedAt: Instant, + fingerprint: String, + payload: ByteArray, + ) = ContentValues().apply { + put("period_start", periodStartEpochDay) + put("recorded_at", recordedAt.toEpochMilli()) + put("fingerprint", fingerprint) + put("report_blob", payload) + } + + private fun requireValidRevision(fingerprint: String, payload: ByteArray) { + require(fingerprint.matches(FINGERPRINT)) + require(payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) + } + + private val FINGERPRINT = Regex("[0-9a-f]{64}") +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt index 5223ab9..e779cce 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt @@ -8,12 +8,49 @@ import java.time.ZoneId internal object WeeklyHistoryWorkPolicy { const val MAX_CHANGED_WEEKS_PER_INGEST = 32 - fun changedPeriodStarts( + /** + * Every week whose live revision can change when evidence at [instants] is stored: the game + * week of each instant, then the listed neighbours that also read it. A roster at exactly a + * Sunday 05:00 reset is the closing Saturday capture of the previous week; an instant before + * 05:00 on a calendar Sunday falls in the next week's calendar-day Join / Withdraw section; + * and every later week up to the next stored roster carries the instant in as its baseline + * roster or as a roster-changing membership event. Neighbours are limited to + * [listedPeriodStarts], so no revision is recorded for a week the history does not list, and + * the whole result stays within [MAX_CHANGED_WEEKS_PER_INGEST], game weeks first. + */ + fun affectedPeriodStarts( instants: Sequence, zoneId: ZoneId, - ): List = instants - .map { PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(it, zoneId)) } - .distinct() - .take(MAX_CHANGED_WEEKS_PER_INGEST) - .toList() + listedPeriodStarts: Collection, + nextSnapshotAfter: (Instant) -> Instant?, + ): List { + val latestByWeek = linkedMapOf() + val neighbours = sortedSetOf() + instants.forEach { instant -> + val week = PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(instant, zoneId)) + val latest = latestByWeek[week] + if (latest == null && latestByWeek.size == MAX_CHANGED_WEEKS_PER_INGEST) return@forEach + if (latest == null || instant.isAfter(latest)) latestByWeek[week] = instant + if (instant == PlatoonPeriods.periodStartInstant(week, zoneId)) { + neighbours += week.minusDays(DAYS_PER_WEEK) + } + neighbours += PlatoonPeriods.weekStart(instant.atZone(zoneId).toLocalDate()) + } + val listed = listedPeriodStarts.distinct().sorted() + latestByWeek.forEach { (week, latest) -> + val nextSnapshot = nextSnapshotAfter(latest) + listed.asSequence() + .dropWhile { !it.isAfter(week) } + .takeWhile { later -> + nextSnapshot == null || + !nextSnapshot.isBefore(PlatoonPeriods.periodStartInstant(later, zoneId)) + } + .forEach(neighbours::add) + } + return (latestByWeek.keys + neighbours.filter { it in listed }) + .distinct() + .take(MAX_CHANGED_WEEKS_PER_INGEST) + } + + private const val DAYS_PER_WEEK = 7L } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt index f1536fe..9abc725 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt @@ -12,16 +12,11 @@ object WeeklyMetricPresentation { else -> "?" } - // Function Name: warnsBelowCutline - // Description: - // - Marks a displayed exact value or confirmed lower bound when it remains below its cutoff. - // - Leaves unknown values and lower bounds that already meet the cutoff unmarked. - // Parameters: - // - value: Numeric metric value rendered in the weekly table, or null when unavailable. - // - certainty: Whether the value is exact, a lower bound, or unknown. - // - belowCutline: Metric-specific cutoff comparison. - // Returns: - // - Returns true when the displayed value should use the cutoff warning color. + /** + * Marks a displayed exact value or confirmed lower bound when it remains below its cutoff. + * Leaves unknown values and lower bounds that already meet the cutoff unmarked. Returns true + * when the displayed value should use the cutoff warning color. + */ fun warnsBelowCutline( value: T?, certainty: MetricCertainty, diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt index f5da951..c5a9aea 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt @@ -1,11 +1,21 @@ package dev.gf2log.app.management +import java.time.LocalDate + /** Keeps live weekly notes representable by the immutable weekly-history format. */ internal object WeeklyNotePolicy { - const val MAX_MANUAL_NOTES_PER_WEEK = 128 + const val MAX_MANUAL_NOTES_PER_WEEK = WeeklyReportHistoryCodec.MAX_NOTES fun canAdd(manualNoteCount: Int): Boolean = manualNoteCount in 0 until MAX_MANUAL_NOTES_PER_WEEK + + /** + * True when [gameDay] lies in the Sunday-based week starting at [periodStart]. Weekly notes + * and cell overrides outside their week would fail every later revision audit of that week. + */ + fun isWithinWeek(periodStart: LocalDate, gameDay: LocalDate): Boolean = + PlatoonPeriods.weekStart(periodStart) == periodStart && + gameDay in periodStart..periodStart.plusDays(6) } /** Signals the one user-correctable weekly-note capacity failure. */ diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt new file mode 100644 index 0000000..44265b0 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt @@ -0,0 +1,118 @@ +package dev.gf2log.app.management + +import java.time.LocalDate + +/** + * Merges the user's manual weekly-table edits with the overrides already stored for a week. + * + * Only explicitly edited fields change; every untouched field keeps its stored override value so + * derived certainty is never replaced by an accidental edit. The result is the complete override + * set for the week, because the repository replaces a week's overrides as one unit. + */ +object WeeklyOverrideEditing { + /** The invalid input; [ACTIVITY] is a Login NO with a Daily Patrol YES in the same edit. */ + enum class Field { MERIT, SCORE, ATTEMPTS, ACTIVITY } + + /** A tri-state activity choice: unset clears the manual value, yes/no confirms it. */ + enum class Choice(val value: Boolean?) { + UNSET(null), + YES(true), + NO(false), + ; + + companion object { + fun of(value: Boolean?): Choice = entries.first { it.value == value } + } + } + + /** One cell's edits. A null property means that field was not edited. */ + data class CellEdit( + val uid: Long, + val gameDay: LocalDate, + val merit: String? = null, + val score: String? = null, + val attempts: String? = null, + val attended: Choice? = null, + val dailyPatrol: Choice? = null, + ) { + val isEmpty: Boolean + get() = merit == null && score == null && attempts == null && + attended == null && dailyPatrol == null + } + + sealed interface Result { + data class Valid(val overrides: List) : Result + data class Invalid(val uid: Long, val gameDay: LocalDate, val field: Field) : Result + } + + fun merge( + periodStart: LocalDate, + isGunsmokeWeek: Boolean, + existing: Collection, + edits: Collection, + ): Result { + val existingByCell = existing.associateBy { it.uid to it.gameDay } + val editsByCell = edits.filterNot(CellEdit::isEmpty).associateBy { it.uid to it.gameDay } + val merged = (existingByCell.keys + editsByCell.keys).mapNotNull { cell -> + val stored = existingByCell[cell] + val edit = editsByCell[cell] + fun invalid(field: Field) = Result.Invalid(cell.first, cell.second, field) + val merit = edit?.merit?.let { parseCounter(it) ?: return invalid(Field.MERIT) } + val score = edit?.score?.let { parseCounter(it) ?: return invalid(Field.SCORE) } + val attempts = edit?.attempts?.let { parseAttempts(it) ?: return invalid(Field.ATTEMPTS) } + if (edit?.attended == Choice.NO && edit.dailyPatrol == Choice.YES) { + return invalid(Field.ACTIVITY) + } + // A completed Daily Patrol requires attendance, so an edited choice settles a + // contradiction with the other stored one instead of producing an unstorable pair. + val attended = when { + edit?.attended != null -> edit.attended.value + edit?.dailyPatrol == Choice.YES && stored?.attended == false -> true + else -> stored?.attended + } + val dailyPatrol = when { + edit?.dailyPatrol != null -> edit.dailyPatrol.value + attended == false && stored?.dailyPatrol == true -> false + else -> stored?.dailyPatrol + } + WeeklyCellOverride( + uid = cell.first, + periodStart = periodStart, + gameDay = cell.second, + meritDelta = if (edit?.merit != null) merit?.value else stored?.meritDelta, + scoreDelta = when { + !isGunsmokeWeek -> null + edit?.score != null -> score?.value + else -> stored?.scoreDelta + }, + attempts = when { + !isGunsmokeWeek -> null + edit?.attempts != null -> attempts?.value + else -> stored?.attempts + }, + attended = attended, + dailyPatrol = dailyPatrol, + ).takeIf { it.hasAnyValue() } + } + return Result.Valid(merged.sortedWith(compareBy({ it.gameDay }, { it.uid }))) + } + + private class Parsed(val value: T?) + + /** Blank clears the field; otherwise a non-negative integer is required. */ + private fun parseCounter(text: String): Parsed? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return Parsed(null) + return trimmed.toLongOrNull()?.takeIf { it >= 0 }?.let { Parsed(it) } + } + + private fun parseAttempts(text: String): Parsed? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return Parsed(null) + return trimmed.toIntOrNull()?.takeIf { it in 0..ActivityInference.MAX_DAILY_ATTEMPTS }?.let { Parsed(it) } + } + + private fun WeeklyCellOverride.hasAnyValue(): Boolean = + meritDelta != null || scoreDelta != null || attempts != null || + attended != null || dailyPatrol != null +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt index a979bef..dfd7859 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -36,9 +36,6 @@ object WeeklyReportBuilder { val resolvedStandardTotals: ResolvedStandardTotals? = null, val gunsmokeAttemptsFloor: Int? = null, ) { - val observedDays: List - get() = days.filter { it.observed } - val totalAttempts: Int? get() { val known = days.mapNotNull(DayCell::attempts) @@ -144,14 +141,6 @@ object WeeklyReportBuilder { loginDaysCertainty, patrolDaysCertainty, ).any { certainty -> certainty != MetricCertainty.EXACT } - - val hasIncompleteEvidence: Boolean - get() = days.any { - it.evidence == DailyEvidence.NO_OBSERVATION || - it.evidence == DailyEvidence.INCOMPLETE_BOUNDARY || - it.evidence == DailyEvidence.PARTIAL_DAY || - it.evidence == DailyEvidence.SPARSE_INFERRED - } } data class ResolvedGunsmokeTotals( @@ -285,16 +274,6 @@ object WeeklyReportBuilder { evidence == DailyEvidence.ATTRIBUTED || evidence == DailyEvidence.PARTIAL_DAY || evidence == DailyEvidence.SPARSE_INFERRED - - val isGunsmokeActivityUnknown: Boolean - get() = manualOverride == null && - !hasDailyPatrolFact && - evidence in setOf( - DailyEvidence.NO_OBSERVATION, - DailyEvidence.INCOMPLETE_BOUNDARY, - DailyEvidence.PARTIAL_DAY, - DailyEvidence.SPARSE_INFERRED, - ) } fun build( @@ -313,25 +292,89 @@ object WeeklyReportBuilder { .filter { it.periodStart == start && it.gameDay in days } .associateBy { it.uid to it.gameDay } val sortedSnapshots = snapshots.sortedBy(PlatoonSnapshot::capturedAt) - val patrolFactsByMemberDay = dailyPatrolFacts - .filter { PlatoonPeriods.gameDay(it.occurredAt, zoneId) in days } - .groupBy { it.uid to PlatoonPeriods.gameDay(it.occurredAt, zoneId) } - .mapValues { (_, facts) -> facts.minBy(DailyPatrolFact::occurredAt) } - val loginFactsByMemberDay = sortedSnapshots - .flatMap { snapshot -> - snapshot.members.mapNotNull { member -> - member.lastLogin - .takeIf { it > 0L } - ?.let(Instant::ofEpochSecond) - ?.let { member.uid to PlatoonPeriods.gameDay(it, zoneId) } - } - } - .filter { it.second in days } - .toSet() - val rosterCutoff = minOf( - PlatoonPeriods.periodStartInstant(start.plusDays(7), zoneId), - asOf.plusMillis(1), + val week = WeekInputs( + days = days, + zoneId = zoneId, + isGunsmoke = isGunsmoke, + snapshots = sortedSnapshots, + overridesByCell = overridesByCell, + dailyPatrolFacts = dailyPatrolFacts, + patrolFactsByMemberDay = dailyPatrolFacts + .filter { PlatoonPeriods.gameDay(it.occurredAt, zoneId) in days } + .groupBy { it.uid to PlatoonPeriods.gameDay(it.occurredAt, zoneId) } + .mapValues { (_, facts) -> facts.minBy(DailyPatrolFact::occurredAt) }, + loginFactsByMemberDay = loginFactsByMemberDay(sortedSnapshots, days, zoneId), + asOf = asOf, ) + val rosterCutoff = rosterCutoff(start, zoneId, asOf) + val identities = memberIdentities( + sortedSnapshots, + membershipEvents, + overridesByCell.keys.map { it.first }, + rosterCutoff, + ) + // Weekly rows represent the roster that is active at the end of the selected reporting + // period (or the latest roster for an in-progress week). A member observed earlier in + // the week disappears as soon as a later complete roster records their withdrawal; the + // immutable membership event remains in the Join / Withdraw section. Rows are solved + // only for that roster because each row depends on its own member's evidence alone. + val rosterAtPeriodEnd = rosterAtCutoff(sortedSnapshots, membershipEvents, rosterCutoff) + + val rows = identities.values + .filter { it.uid in rosterAtPeriodEnd } + .map { latestKnown -> buildRow(latestKnown, week) } + .sortedWith( + if (isGunsmoke) { + compareByDescending { it.totalScore } + .thenByDescending { it.totalMerit } + .thenBy { it.name } + } else { + compareByDescending { it.totalMerit }.thenBy { it.name } + }, + ) + + return Report(start, start.plusDays(6), isGunsmoke, days, rows) + } + + /** Everything one member row is derived from; shared by every row of the week. */ + private class WeekInputs( + val days: List, + val zoneId: ZoneId, + val isGunsmoke: Boolean, + val snapshots: List, + val overridesByCell: Map, WeeklyCellOverride>, + val dailyPatrolFacts: List, + val patrolFactsByMemberDay: Map, DailyPatrolFact>, + val loginFactsByMemberDay: Set>, + val asOf: Instant, + ) + + private fun loginFactsByMemberDay( + sortedSnapshots: List, + days: List, + zoneId: ZoneId, + ): Set> = sortedSnapshots + .flatMap { snapshot -> + snapshot.members.mapNotNull { member -> + member.lastLogin + .takeIf { it > 0L } + ?.let(Instant::ofEpochSecond) + ?.let { member.uid to PlatoonPeriods.gameDay(it, zoneId) } + } + } + .filter { it.second in days } + .toSet() + + /** + * The latest known identity of every member the week can show: roster observations, newer + * exact Updates names or joins before [rosterCutoff], and placeholders for manual overrides. + */ + private fun memberIdentities( + sortedSnapshots: List, + membershipEvents: List, + overriddenUids: List, + rosterCutoff: Instant, + ): Map { val uids = sortedSnapshots.flatMap { it.members }.associateBy(SnapshotMember::uid).toMutableMap() val latestSnapshotAtByUid = buildMap { sortedSnapshots.forEach { snapshot -> @@ -368,112 +411,121 @@ object WeeklyReportBuilder { ) } } - overridesByCell.keys.map { it.first }.forEach { uid -> + overriddenUids.forEach { uid -> if (uid !in uids) { uids[uid] = SnapshotMember(uid, "#$uid", 0, 0, 0, 0, 0, 0) } } - val rosterSnapshot = sortedSnapshots.lastBefore(rosterCutoff) - val rosterAtPeriodEnd = applyMembershipEventsToRoster( - rosterSnapshot?.members?.map(SnapshotMember::uid).orEmpty().toSet(), - membershipEvents, - rosterSnapshot?.capturedAt, - rosterCutoff, - ) + return uids + } - val rows = uids.values.map { latestKnown -> - val derivedCells = days.map { day -> - buildCell( - uid = latestKnown.uid, - day = day, - zoneId = zoneId, - snapshots = sortedSnapshots, - isGunsmoke = isGunsmoke, - manualOverride = overridesByCell[latestKnown.uid to day], - dailyPatrolFact = patrolFactsByMemberDay[latestKnown.uid to day], - hasLoginFact = latestKnown.uid to day in loginFactsByMemberDay, - ) - } - val gunsmokeResolution = if (isGunsmoke) { - GunsmokeWeekSolver.resolve( - uid = latestKnown.uid, - days = days, - zoneId = zoneId, - snapshots = sortedSnapshots, - cells = derivedCells, - dailyPatrolFacts = dailyPatrolFacts, - ) - } else { - null - } - val standardResolution = if (!isGunsmoke) { - StandardWeekSolver.resolve( - uid = latestKnown.uid, - days = days, - zoneId = zoneId, - snapshots = sortedSnapshots, - cells = derivedCells, - asOf = asOf, - ) - } else { - null - } - val cells = gunsmokeResolution?.cells ?: standardResolution?.cells ?: derivedCells - val snapshotsInPeriod = sortedSnapshots.mapNotNull { snapshot -> - val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) - snapshot.member(latestKnown.uid) - ?.takeIf { gameDay in days } - ?.let { gameDay to it } - } - val derivedMeritTotal = cells.sumOf { it.meritDelta ?: 0L } - val sundayMerit = cells.firstOrNull()?.meritDelta ?: 0L - val mondayThroughSaturday = snapshotsInPeriod - .lastOrNull { (gameDay, _) -> gameDay.dayOfWeek.value in 1..6 } - ?.second - ?.weeklyMerit - ?: 0L - val derivedScoreTotal = cells.sumOf { it.scoreDelta ?: 0L } - val latestCapturedScore = snapshotsInPeriod.lastOrNull()?.second?.totalScore ?: 0L - MemberRow( - uid = latestKnown.uid, - name = latestKnown.name, - days = cells, - totalMerit = maxOf( - derivedMeritTotal, - sundayMerit + mondayThroughSaturday, - gunsmokeResolution?.totals?.merit ?: 0L, - standardResolution?.totals?.merit ?: 0L, - ), - totalScore = if (isGunsmoke) { - maxOf(derivedScoreTotal, latestCapturedScore) - } else { - derivedScoreTotal - }, - isGunsmokeWeek = isGunsmoke, - hasFinalGunsmokeScore = cells.any(DayCell::hasFinalGunsmokeScore), - resolvedGunsmokeTotals = gunsmokeResolution?.totals, - resolvedStandardTotals = standardResolution?.totals, - gunsmokeAttemptsFloor = gunsmokeResolution?.attemptsFloor, + private fun buildRow(latestKnown: SnapshotMember, week: WeekInputs): MemberRow { + val uid = latestKnown.uid + val derivedCells = week.days.map { day -> + buildCell( + uid = uid, + day = day, + zoneId = week.zoneId, + snapshots = week.snapshots, + isGunsmoke = week.isGunsmoke, + manualOverride = week.overridesByCell[uid to day], + dailyPatrolFact = week.patrolFactsByMemberDay[uid to day], + hasLoginFact = uid to day in week.loginFactsByMemberDay, ) - }.filter { row -> - // Weekly rows represent the roster that is active at the end of the - // selected reporting period (or the latest roster for an in-progress - // week). A member observed earlier in the week must disappear as soon - // as a later complete roster records their withdrawal; the immutable - // membership event remains available in the Join / Withdraw section. - row.uid in rosterAtPeriodEnd } - .sortedWith( - if (isGunsmoke) { - compareByDescending { it.totalScore } - .thenByDescending { it.totalMerit } - .thenBy { it.name } - } else { - compareByDescending { it.totalMerit }.thenBy { it.name } - }, + val gunsmokeResolution = if (week.isGunsmoke) { + GunsmokeWeekSolver.resolve( + uid = uid, + days = week.days, + zoneId = week.zoneId, + snapshots = week.snapshots, + cells = derivedCells, + dailyPatrolFacts = week.dailyPatrolFacts, ) + } else { + null + } + val standardResolution = if (!week.isGunsmoke) { + StandardWeekSolver.resolve( + uid = uid, + days = week.days, + zoneId = week.zoneId, + snapshots = week.snapshots, + cells = derivedCells, + asOf = week.asOf, + ) + } else { + null + } + val cells = gunsmokeResolution?.cells ?: standardResolution?.cells ?: derivedCells + val snapshotsInPeriod = week.snapshots.mapNotNull { snapshot -> + val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, week.zoneId) + snapshot.member(uid) + ?.takeIf { gameDay in week.days } + ?.let { gameDay to it } + } + val derivedMeritTotal = cells.sumOf { it.meritDelta ?: 0L } + val sundayMerit = cells.firstOrNull()?.meritDelta ?: 0L + val mondayThroughSaturday = snapshotsInPeriod + .lastOrNull { (gameDay, _) -> gameDay.dayOfWeek.value in 1..6 } + ?.second + ?.weeklyMerit + ?: 0L + val derivedScoreTotal = cells.sumOf { it.scoreDelta ?: 0L } + val latestCapturedScore = snapshotsInPeriod.lastOrNull()?.second?.totalScore ?: 0L + return MemberRow( + uid = uid, + name = latestKnown.name, + days = cells, + totalMerit = maxOf( + derivedMeritTotal, + sundayMerit + mondayThroughSaturday, + gunsmokeResolution?.totals?.merit ?: 0L, + standardResolution?.totals?.merit ?: 0L, + ), + totalScore = if (week.isGunsmoke) { + maxOf(derivedScoreTotal, latestCapturedScore) + } else { + derivedScoreTotal + }, + isGunsmokeWeek = week.isGunsmoke, + hasFinalGunsmokeScore = cells.any(DayCell::hasFinalGunsmokeScore), + resolvedGunsmokeTotals = gunsmokeResolution?.totals, + resolvedStandardTotals = standardResolution?.totals, + gunsmokeAttemptsFloor = gunsmokeResolution?.attemptsFloor, + ) + } - return Report(start, start.plusDays(6), isGunsmoke, days, rows) + /** UIDs whose rows the report for [referenceDay] contains, without solving any row. */ + internal fun rosterAtPeriodEnd( + referenceDay: LocalDate, + zoneId: ZoneId, + snapshots: List, + membershipEvents: List, + asOf: Instant, + ): Set = rosterAtCutoff( + snapshots.sortedBy(PlatoonSnapshot::capturedAt), + membershipEvents, + rosterCutoff(PlatoonPeriods.weekStart(referenceDay), zoneId, asOf), + ) + + private fun rosterCutoff(periodStart: LocalDate, zoneId: ZoneId, asOf: Instant): Instant = minOf( + PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId), + asOf.plusMillis(1), + ) + + private fun rosterAtCutoff( + sortedSnapshots: List, + membershipEvents: List, + cutoff: Instant, + ): Set { + val rosterSnapshot = sortedSnapshots.lastBefore(cutoff) + return applyMembershipEventsToRoster( + rosterSnapshot?.members?.map(SnapshotMember::uid).orEmpty().toSet(), + membershipEvents, + rosterSnapshot?.capturedAt, + cutoff, + ) } private fun buildCell( @@ -497,19 +549,11 @@ object WeeklyReportBuilder { dailyPatrolFact = dailyPatrolFact, hasLoginFact = hasLoginFact, ) - val merit = manualOverride.meritDelta ?: derived.meritDelta - val score = manualOverride.scoreDelta ?: derived.scoreDelta - return derived.copy( - meritDelta = merit, - scoreDelta = score, - inference = if (merit != null && score != null) { - ActivityInference.infer(merit, score, isGunsmoke) - } else { - derived.inference - }, - manualOverride = manualOverride, - ) + return overlayManualOverride(derived, manualOverride, isGunsmoke) } + fun withActivityFacts(cell: DayCell) = + applyActivityFacts(cell, isGunsmoke, dailyPatrolFact, hasLoginFact) + val start = PlatoonPeriods.periodStartInstant(day, zoneId) val end = PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId) val observations = snapshots.filter { @@ -529,7 +573,7 @@ object WeeklyReportBuilder { } == true val hasClosingBoundary = lastObservation?.first?.capturedAt == end if (baselineSnapshot == null) { - return applyActivityFacts( + return withActivityFacts( DayCell( gameDay = day, meritDelta = null, @@ -544,28 +588,10 @@ object WeeklyReportBuilder { isGunsmokeWeek = isGunsmoke, hasClosingBoundary = hasClosingBoundary, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, - ) - } - if (observations.isEmpty()) { - return applyActivityFacts( - DayCell( - day, - null, - null, - null, - DailyEvidence.NO_OBSERVATION, - isGunsmokeWeek = isGunsmoke, - ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, ) } - if (after == null && before == null) { - return applyActivityFacts( + if (observations.isEmpty() || (after == null && before == null)) { + return withActivityFacts( DayCell( day, null, @@ -574,9 +600,6 @@ object WeeklyReportBuilder { DailyEvidence.NO_OBSERVATION, isGunsmokeWeek = isGunsmoke, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, ) } if (isGunsmoke && baselineSnapshot.capturedAt != start) { @@ -584,7 +607,7 @@ object WeeklyReportBuilder { // Its counter delta is an interval total, not a safe lower bound // for the new Gunsmoke day. The path solver may still reconcile it // with adjacent checkpoints; otherwise keep the daily metrics unknown. - return applyActivityFacts( + return withActivityFacts( DayCell( gameDay = day, meritDelta = null, @@ -596,9 +619,6 @@ object WeeklyReportBuilder { metricObservedAt = lastObservation?.first?.capturedAt, hasClosingBoundary = hasClosingBoundary, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, ) } @@ -613,14 +633,14 @@ object WeeklyReportBuilder { scoreDelta = if (isGunsmoke) null else 0L } else { val lastKnown = after ?: before - meritDelta = counterDelta(lastKnown.totalMerit, before.totalMerit) + meritDelta = ActivityInference.counterDelta(before.totalMerit, lastKnown.totalMerit) scoreDelta = if (isGunsmoke) { - counterDelta(lastKnown.totalScore, before.totalScore) + ActivityInference.counterDelta(before.totalScore, lastKnown.totalScore) } else { 0L } } - return applyActivityFacts( + return withActivityFacts( DayCell( gameDay = day, meritDelta = meritDelta, @@ -635,9 +655,26 @@ object WeeklyReportBuilder { metricObservedAt = lastObservation?.first?.capturedAt, hasClosingBoundary = hasClosingBoundary, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, + ) + } + + /** Overlays only the explicitly edited fields; untouched values keep their derivation. */ + private fun overlayManualOverride( + derived: DayCell, + manualOverride: WeeklyCellOverride, + isGunsmoke: Boolean, + ): DayCell { + val merit = manualOverride.meritDelta ?: derived.meritDelta + val score = manualOverride.scoreDelta ?: derived.scoreDelta + return derived.copy( + meritDelta = merit, + scoreDelta = score, + inference = if (merit != null && score != null) { + ActivityInference.infer(merit, score, isGunsmoke) + } else { + derived.inference + }, + manualOverride = manualOverride, ) } @@ -720,16 +757,8 @@ object WeeklyReportBuilder { else -> MetricCertainty.LOWER_BOUND } - - private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = - members.firstOrNull { it.uid == uid } - - private fun counterDelta(current: Long, previous: Long): Long = - if (current >= previous) current - previous else current - private const val BOUNDARY_WINDOW_MINUTES = 15L - private const val MAX_STANDARD_DAILY_MERIT = 90L - private const val MAX_STANDARD_WEEKLY_MERIT = MAX_STANDARD_DAILY_MERIT * 7 - private const val STANDARD_LOGIN_MERIT = 50L - private const val STANDARD_PATROL_MERIT = 90L + private const val MAX_STANDARD_WEEKLY_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT * 7 + private const val STANDARD_LOGIN_MERIT = ActivityInference.LOGIN_MERIT + private const val STANDARD_PATROL_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt index 7327098..09a22e9 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt @@ -5,7 +5,6 @@ import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream import java.nio.charset.StandardCharsets -import java.security.MessageDigest import java.time.Instant import java.time.LocalDate import java.util.zip.GZIPInputStream @@ -28,10 +27,24 @@ internal object WeeklyReportHistoryCodec { require(revision.notes.size <= MAX_NOTES) require(revision.memberNamesByUid.size <= MAX_CONTEXT_MEMBERS) require(revision.memberPrivateNotesByUid.size <= MAX_MEMBERS) + val overrides = report.members.flatMap { member -> + member.days.mapIndexedNotNull { index, cell -> + cell.manualOverride?.let { override -> + require( + override.uid == member.uid && + override.periodStart == report.periodStart && + override.gameDay == cell.gameDay, + ) { "A manual override must belong to its cell" } + index to override + } + } + } + // A revision without overrides keeps its version-2 bytes, so its fingerprint is stable. + val formatVersion = if (overrides.isEmpty()) NOTES_FORMAT_VERSION else FORMAT_VERSION val raw = ByteArrayOutputStream().use { buffer -> DataOutputStream(buffer).use { output -> output.writeInt(MAGIC) - output.writeInt(FORMAT_VERSION) + output.writeInt(formatVersion) output.writeLong(report.periodStart.toEpochDay()) output.writeLong(report.periodEnd.toEpochDay()) output.writeBoolean(report.isGunsmokeWeek) @@ -45,6 +58,10 @@ internal object WeeklyReportHistoryCodec { revision.notes.forEach { output.writeWeeklyNote(it) } output.writeStringMap(revision.memberNamesByUid, MAX_STRING_BYTES) output.writeStringMap(revision.memberPrivateNotesByUid, MAX_PRIVATE_NOTE_BYTES) + if (formatVersion >= OVERRIDES_FORMAT_VERSION) { + output.writeInt(overrides.size) + overrides.forEach { (dayIndex, override) -> output.writeOverride(dayIndex, override) } + } } buffer.toByteArray() } @@ -54,9 +71,7 @@ internal object WeeklyReportHistoryCodec { compressed.toByteArray() } require(payload.size <= MAX_PAYLOAD_BYTES) { "Weekly history payload is too large" } - val fingerprint = MessageDigest.getInstance("SHA-256") - .digest(raw) - .joinToString("") { "%02x".format(it) } + val fingerprint = raw.sha256().toHex() return Encoded(payload, fingerprint) } @@ -104,13 +119,67 @@ internal object WeeklyReportHistoryCodec { val notes = List(noteCount) { input.readWeeklyNote(periodStart) } val names = input.readStringMap(MAX_CONTEXT_MEMBERS, MAX_STRING_BYTES) val privateNotes = input.readStringMap(MAX_MEMBERS, MAX_PRIVATE_NOTE_BYTES) - WeeklyTableRevision(report, events, notes, names, privateNotes) + val withOverrides = if (formatVersion >= OVERRIDES_FORMAT_VERSION) { + input.readOverrides(report) + } else { + report + } + WeeklyTableRevision(withOverrides, events, notes, names, privateNotes) } require(input.read() == -1) { "Trailing weekly history data" } ManagementConsistencyAudit.requireValid(revision) } } + private fun DataOutputStream.writeOverride(dayIndex: Int, override: WeeklyCellOverride) { + writeLong(override.uid) + writeInt(dayIndex) + writeNullableLong(override.meritDelta) + writeNullableLong(override.scoreDelta) + writeNullableInt(override.attempts) + writeNullableBoolean(override.attended) + writeNullableBoolean(override.dailyPatrol) + } + + /** Attaches each recorded manual override to its cell; every cell takes at most one. */ + private fun DataInputStream.readOverrides( + report: WeeklyReportBuilder.Report, + ): WeeklyReportBuilder.Report { + val count = readBoundedCount(MAX_MEMBERS * DAYS_PER_WEEK) + val rowsByUid = report.members.associateBy { it.uid } + val overrides = mutableMapOf, WeeklyCellOverride>() + repeat(count) { + val uid = readLong() + val dayIndex = readInt() + require(uid in rowsByUid && dayIndex in 0 until DAYS_PER_WEEK) { + "A manual override must belong to a recorded cell" + } + val override = WeeklyCellOverride( + uid = uid, + periodStart = report.periodStart, + gameDay = report.days[dayIndex], + meritDelta = readNullableLong(), + scoreDelta = readNullableLong(), + attempts = readNullableInt(), + attended = readNullableBoolean(), + dailyPatrol = readNullableBoolean(), + ) + require(overrides.put(uid to dayIndex, override) == null) { + "A weekly cell can have only one override" + } + } + if (overrides.isEmpty()) return report + return report.copy( + members = report.members.map { member -> + member.copy( + days = member.days.mapIndexed { index, cell -> + overrides[member.uid to index]?.let { cell.copy(manualOverride = it) } ?: cell + }, + ) + }, + ) + } + private fun DataOutputStream.writeMemberEvent(event: MemberEvent) { writeLong(event.id) writeLong(event.uid) @@ -406,15 +475,17 @@ internal object WeeklyReportHistoryCodec { private const val MAGIC = 0x47463248 private const val LEGACY_FORMAT_VERSION = 1 - private const val FORMAT_VERSION = 2 + private const val NOTES_FORMAT_VERSION = 2 + private const val OVERRIDES_FORMAT_VERSION = 3 + private const val FORMAT_VERSION = OVERRIDES_FORMAT_VERSION private const val DAYS_PER_WEEK = 7 private const val MAX_MEMBERS = 256 - private const val MAX_CONTEXT_MEMBERS = 768 - private const val MAX_EVENTS = 512 - private const val MAX_NOTES = 128 - private const val MAX_STRING_BYTES = 1_024 - private const val MAX_NOTE_BYTES = 16 * 1_024 - private const val MAX_PRIVATE_NOTE_BYTES = 2 * 1_024 + const val MAX_CONTEXT_MEMBERS = 768 + const val MAX_EVENTS = 512 + const val MAX_NOTES = 128 + const val MAX_STRING_BYTES = 1_024 + const val MAX_NOTE_BYTES = 16 * 1_024 + const val MAX_PRIVATE_NOTE_BYTES = 2 * 1_024 private const val MAX_DECOMPRESSED_BYTES = 4 * 1024 * 1024 private const val STREAM_BUFFER_BYTES = 8 * 1024 const val MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt index a40efe1..7705785 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt @@ -10,6 +10,21 @@ object WeeklyShareProjection { val includePrivateNotes: Boolean = false, ) + /** Every word that appears in the image; the app passes its active language. */ + data class Labels( + val standardTitle: String = "GF2logger \u2014 Standard week", + val gunsmokeTitle: String = "GF2logger \u2014 Gunsmoke week", + val member: String = "Member", + val total: String = "Total", + val privateNote: String = "Private note", + val anonymousMember: (Int) -> String = { "Member $it" }, + val merit: String = "M", + val score: String = "S", + val attempts: String = "A", + val login: String = "L", + val patrol: String = "P", + ) + data class Document( val title: String, val subtitle: String, @@ -31,27 +46,28 @@ object WeeklyShareProjection { displayedMembers: List, privateNotesByUid: Map, privacy: Privacy, + labels: Labels = Labels(), ): Document { val reportUids = report.members.mapTo(mutableSetOf()) { it.uid } require(displayedMembers.all { it.uid in reportUids }) val headers = buildList { - add("Member") + add(labels.member) addAll(report.days.map { it.format(DAY) }) - add("Total") - if (privacy.includePrivateNotes) add("Private note") + add(labels.total) + if (privacy.includePrivateNotes) add(labels.privateNote) } val rows = displayedMembers.mapIndexed { index, member -> Row( - member = memberLabel(member, index, privacy), - dailyCells = member.days.map { formatDaily(it, report.isGunsmokeWeek) }, - total = formatTotal(member, report.isGunsmokeWeek), + member = memberLabel(member, index, privacy, labels), + dailyCells = member.days.map { formatDaily(it, report.isGunsmokeWeek, labels) }, + total = formatTotal(member, report.isGunsmokeWeek, labels), privateNote = privateNotesByUid[member.uid] - ?.take(MAX_PRIVATE_NOTE_CHARS) + ?.takeWhole(MAX_PRIVATE_NOTE_CHARS) ?.takeIf { privacy.includePrivateNotes && it.isNotBlank() }, ) } return Document( - title = if (report.isGunsmokeWeek) "GF2logger \u2014 Gunsmoke week" else "GF2logger \u2014 Standard week", + title = if (report.isGunsmokeWeek) labels.gunsmokeTitle else labels.standardTitle, subtitle = report.periodStart.toString() + " \u2014 " + report.periodEnd.toString(), headers = headers, rows = rows, @@ -64,38 +80,41 @@ object WeeklyShareProjection { member: WeeklyReportBuilder.MemberRow, index: Int, privacy: Privacy, + labels: Labels, ): String = when { privacy.includeMemberNames && privacy.includeUids -> - member.name.take(MAX_MEMBER_LABEL_CHARS) + " (#" + member.uid + ")" - privacy.includeMemberNames -> member.name.take(MAX_MEMBER_LABEL_CHARS) + member.name.takeWhole(MAX_MEMBER_LABEL_CHARS) + " (#" + member.uid + ")" + privacy.includeMemberNames -> member.name.takeWhole(MAX_MEMBER_LABEL_CHARS) privacy.includeUids -> "#" + member.uid - else -> "Member " + (index + 1) + else -> labels.anonymousMember(index + 1) } private fun formatDaily( cell: WeeklyReportBuilder.DayCell, gunsmoke: Boolean, + labels: Labels, ): String = buildList { - add("M " + WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty)) + add(labels.merit + " " + WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty)) if (gunsmoke) { - add("S " + WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty)) - add("A " + WeeklyMetricPresentation.format(cell.attempts, cell.attemptsCertainty)) + add(labels.score + " " + WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty)) + add(labels.attempts + " " + WeeklyMetricPresentation.format(cell.attempts, cell.attemptsCertainty)) } - add("L " + activityMark(cell.attended)) - add("P " + activityMark(cell.dailyPatrol)) + add(labels.login + " " + activityMark(cell.attended)) + add(labels.patrol + " " + activityMark(cell.dailyPatrol)) }.joinToString("\n") private fun formatTotal( member: WeeklyReportBuilder.MemberRow, gunsmoke: Boolean, + labels: Labels, ): String = buildList { - add("M " + WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty)) + add(labels.merit + " " + WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty)) if (gunsmoke) { - add("S " + WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty)) - add("A " + WeeklyMetricPresentation.format(member.totalAttempts, member.totalAttemptsCertainty)) + add(labels.score + " " + WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty)) + add(labels.attempts + " " + WeeklyMetricPresentation.format(member.totalAttempts, member.totalAttemptsCertainty)) } - add("L " + WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty)) - add("P " + WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty)) + add(labels.login + " " + WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty)) + add(labels.patrol + " " + WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty)) }.joinToString("\n") private fun activityMark(value: Boolean?): String = when (value) { @@ -105,6 +124,13 @@ object WeeklyShareProjection { } private val DAY = DateTimeFormatter.ofPattern("MM/dd") + /** At most [maximum] UTF-16 units, never ending on the high half of a surrogate pair. */ + private fun String.takeWhole(maximum: Int): String { + if (length <= maximum) return this + val end = if (Character.isHighSurrogate(this[maximum - 1])) maximum - 1 else maximum + return substring(0, end) + } + private const val MAX_MEMBER_LABEL_CHARS = 128 private const val MAX_PRIVATE_NOTE_CHARS = 512 } diff --git a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt index 5d11e3a..c5dde51 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt @@ -1,9 +1,11 @@ package dev.gf2log.app.settings import dev.gf2log.app.StrictProperties +import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.PayloadCatalog import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.time.ZoneId import java.util.Properties data class AppBackupSettings( @@ -34,14 +36,6 @@ object AppBackupSettingsCodec { private const val KEY_DETAILED_NOTIFICATIONS = "detailedNotifications" private const val KEY_TARGET_PACKAGE = "targetPackage" private const val KEY_MEMBER_ORDER = "memberOrder" - private const val KEY_DAILY_MERIT = "cutline.dailyMerit" - private const val KEY_DAILY_SCORE = "cutline.dailyGunsmokeScore" - private const val KEY_DAILY_ATTEMPTS = "cutline.dailyGunsmokeAttempts" - private const val KEY_WEEKLY_MERIT = "cutline.weeklyMerit" - private const val KEY_WEEKLY_SCORE = "cutline.weeklyGunsmokeScore" - private const val KEY_WEEKLY_ATTEMPTS = "cutline.weeklyGunsmokeAttempts" - private const val KEY_WEEKLY_LOGIN_DAYS = "cutline.weeklyLoginDays" - private const val KEY_WEEKLY_PATROL_DAYS = "cutline.weeklyPatrolDays" fun encode(settings: AppBackupSettings): ByteArray { validate(settings) @@ -55,14 +49,9 @@ object AppBackupSettingsCodec { setProperty(KEY_DETAILED_NOTIFICATIONS, settings.detailedNotifications.toString()) setProperty(KEY_TARGET_PACKAGE, settings.targetPackage) setProperty(KEY_MEMBER_ORDER, settings.memberOrder.joinToString(",")) - setProperty(KEY_DAILY_MERIT, settings.weeklyCutlines.dailyMerit.encoded()) - setProperty(KEY_DAILY_SCORE, settings.weeklyCutlines.dailyGunsmokeScore.encoded()) - setProperty(KEY_DAILY_ATTEMPTS, settings.weeklyCutlines.dailyGunsmokeAttempts.encoded()) - setProperty(KEY_WEEKLY_MERIT, settings.weeklyCutlines.weeklyMerit.encoded()) - setProperty(KEY_WEEKLY_SCORE, settings.weeklyCutlines.weeklyGunsmokeScore.encoded()) - setProperty(KEY_WEEKLY_ATTEMPTS, settings.weeklyCutlines.weeklyGunsmokeAttempts.encoded()) - setProperty(KEY_WEEKLY_LOGIN_DAYS, settings.weeklyCutlines.weeklyLoginDays.encoded()) - setProperty(KEY_WEEKLY_PATROL_DAYS, settings.weeklyCutlines.weeklyPatrolDays.encoded()) + WeeklyCutlineField.entries.forEach { field -> + setProperty(field.backupKey, field.valueIn(settings.weeklyCutlines)?.toString() ?: NONE) + } settings.payloadHistory.toSortedMap().forEach { (payloadType, enabled) -> setProperty(payloadKey(payloadType), enabled.toString()) } @@ -100,7 +89,7 @@ object AppBackupSettingsCodec { gameTimeZoneId = if (schemaVersion >= TIME_ZONE_SCHEMA_VERSION) { properties.required(KEY_GAME_TIME_ZONE) } else { - java.time.ZoneId.systemDefault().id + ZoneId.systemDefault().id }, onboardingCompleted = if (schemaVersion >= THEME_SCHEMA_VERSION) { properties.strictBoolean(KEY_ONBOARDING_COMPLETED) @@ -112,7 +101,7 @@ object AppBackupSettingsCodec { payloadHistory = PayloadCatalog.categories.associate { category -> category.payloadType to if ( schemaVersion < SCHEMA_VERSION && - category.payloadType == dev.gf2log.protocol.Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + category.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE ) { true } else { @@ -120,16 +109,13 @@ object AppBackupSettingsCodec { } }, memberOrder = properties.memberOrder(), - weeklyCutlines = WeeklyCutlines( - dailyMerit = properties.optionalLong(KEY_DAILY_MERIT), - dailyGunsmokeScore = properties.optionalLong(KEY_DAILY_SCORE), - dailyGunsmokeAttempts = properties.optionalInt(KEY_DAILY_ATTEMPTS), - weeklyMerit = properties.optionalLong(KEY_WEEKLY_MERIT), - weeklyGunsmokeScore = properties.optionalLong(KEY_WEEKLY_SCORE), - weeklyGunsmokeAttempts = properties.optionalInt(KEY_WEEKLY_ATTEMPTS), - weeklyLoginDays = properties.optionalInt(KEY_WEEKLY_LOGIN_DAYS), - weeklyPatrolDays = properties.optionalInt(KEY_WEEKLY_PATROL_DAYS), - ), + weeklyCutlines = WeeklyCutlineField.cutlinesOf { field -> + if (field.storedAsInt) { + properties.optionalInt(field.backupKey)?.toLong() + } else { + properties.optionalLong(field.backupKey) + } + }, ) validate(settings) return settings @@ -144,7 +130,7 @@ object AppBackupSettingsCodec { GameServerRegion.fromStored(settings.gameServerRegion).storedValue == settings.gameServerRegion, ) { "Unsupported game server region" } - require(runCatching { java.time.ZoneId.of(settings.gameTimeZoneId) }.isSuccess) { + require(runCatching { ZoneId.of(settings.gameTimeZoneId) }.isSuccess) { "Unsupported game timezone" } require( @@ -162,15 +148,11 @@ object AppBackupSettingsCodec { require(settings.memberOrder.distinct().size == settings.memberOrder.size) { "Member order contains duplicate UIDs" } - settings.weeklyCutlines.run { - requireNonNegative(dailyMerit, KEY_DAILY_MERIT) - requireNonNegative(dailyGunsmokeScore, KEY_DAILY_SCORE) - requireInRange(dailyGunsmokeAttempts, 0..3, KEY_DAILY_ATTEMPTS) - requireNonNegative(weeklyMerit, KEY_WEEKLY_MERIT) - requireNonNegative(weeklyGunsmokeScore, KEY_WEEKLY_SCORE) - requireInRange(weeklyGunsmokeAttempts, 0..21, KEY_WEEKLY_ATTEMPTS) - requireInRange(weeklyLoginDays, 0..7, KEY_WEEKLY_LOGIN_DAYS) - requireInRange(weeklyPatrolDays, 0..7, KEY_WEEKLY_PATROL_DAYS) + WeeklyCutlineField.entries.forEach { field -> + val value = field.valueIn(settings.weeklyCutlines) + require(value == null || value in field.range) { + "Backup setting ${field.backupKey} is out of range" + } } } @@ -183,7 +165,7 @@ object AppBackupSettingsCodec { } + PayloadCatalog.categories .filterNot { schemaVersion < SCHEMA_VERSION && - it.payloadType == dev.gf2log.protocol.Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + it.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE } .map { payloadKey(it.payloadType) } @@ -222,17 +204,6 @@ object AppBackupSettingsCodec { } } - private fun Long?.encoded() = this?.toString() ?: NONE - private fun Int?.encoded() = this?.toString() ?: NONE - - private fun requireNonNegative(value: Long?, key: String) { - require(value == null || value >= 0L) { "Backup setting $key is out of range" } - } - - private fun requireInRange(value: Int?, range: IntRange, key: String) { - require(value == null || value in range) { "Backup setting $key is out of range" } - } - private val PACKAGE_NAME = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+") private val BASE_KEYS = setOf( KEY_SCHEMA_VERSION, @@ -244,15 +215,7 @@ object AppBackupSettingsCodec { KEY_DETAILED_NOTIFICATIONS, KEY_TARGET_PACKAGE, KEY_MEMBER_ORDER, - KEY_DAILY_MERIT, - KEY_DAILY_SCORE, - KEY_DAILY_ATTEMPTS, - KEY_WEEKLY_MERIT, - KEY_WEEKLY_SCORE, - KEY_WEEKLY_ATTEMPTS, - KEY_WEEKLY_LOGIN_DAYS, - KEY_WEEKLY_PATROL_DAYS, - ) + ) + WeeklyCutlineField.entries.map(WeeklyCutlineField::backupKey) private val LEGACY_BASE_KEYS = BASE_KEYS - setOf( KEY_THEME_MODE, diff --git a/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt b/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt index 03a4e7a..90ec5e6 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.settings import android.content.Context +import java.time.ZoneId internal interface BackupSettingsStore { fun read(): AppBackupSettings @@ -34,30 +35,54 @@ internal class ScopedAppSettingsStore( ) } + /** + * Replaces four preference files, which SharedPreferences cannot commit atomically. Every + * value is validated before the first write, and each committed file is restored in reverse + * order if a later one fails, so a failure leaves the previous settings in place. + */ override fun replace(settings: AppBackupSettings) { - val previousGlobal = UserSettingsPreferences.read(appContext) - UserSettingsPreferences.replace( - appContext, - settings.copy( - gameServerRegion = previousGlobal.gameServerRegion, - gameTimeZoneId = previousGlobal.gameTimeZoneId, - memberOrder = previousGlobal.memberOrder, - weeklyCutlines = previousGlobal.weeklyCutlines, - ), - ) + AppBackupSettingsCodec.encode(settings) val region = GameServerRegion.fromStored(settings.gameServerRegion) - if (region == GameServerRegion.MANUAL) { - GameTimeZonePreferences.set( + val manualZone = if (region == GameServerRegion.MANUAL) ZoneId.of(settings.gameTimeZoneId) else null + val previousGlobal = UserSettingsPreferences.read(appContext) + val memberOrder = MemberOrderPreferences(appContext, storageId) + val cutlines = WeeklyCutlinePreferences(appContext, storageId) + val previousOrder = memberOrder.read() + val previousCutlines = cutlines.read() + val previousZone = GameTimeZonePreferences.scopedStateOrNull(appContext, storageId) + val rollbacks = ArrayDeque<() -> Unit>() + try { + UserSettingsPreferences.replace( appContext, - java.time.ZoneId.of(settings.gameTimeZoneId), - storageId, + settings.copy( + gameServerRegion = previousGlobal.gameServerRegion, + gameTimeZoneId = previousGlobal.gameTimeZoneId, + memberOrder = previousGlobal.memberOrder, + weeklyCutlines = previousGlobal.weeklyCutlines, + ), ) - } else { - GameTimeZonePreferences.setRegion(appContext, region, storageId) - } - check(MemberOrderPreferences(appContext, storageId).write(settings.memberOrder)) { - "Unable to restore the Platoon member order" + rollbacks.addFirst { UserSettingsPreferences.replace(appContext, previousGlobal) } + if (previousZone != null) { + rollbacks.addFirst { + GameTimeZonePreferences.restoreScopedState(appContext, storageId, previousZone) + } + } + if (manualZone != null) { + GameTimeZonePreferences.set(appContext, manualZone, storageId) + } else { + GameTimeZonePreferences.setRegion(appContext, region, storageId) + } + rollbacks.addFirst { check(memberOrder.write(previousOrder)) } + check(memberOrder.write(settings.memberOrder)) { + "Unable to restore the Platoon member order" + } + rollbacks.addFirst { cutlines.write(previousCutlines) } + cutlines.write(settings.weeklyCutlines) + } catch (error: Exception) { + rollbacks.forEach { rollback -> + runCatching(rollback).exceptionOrNull()?.let(error::addSuppressed) + } + throw error } - WeeklyCutlinePreferences(appContext, storageId).write(settings.weeklyCutlines) } } diff --git a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt index 3dc5389..30862a7 100644 --- a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt @@ -34,24 +34,39 @@ internal object GameTimeZonePreferences { storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID && !scoped(context).contains(regionKey(storageId)) - fun clearRegionOverride(context: Context, storageId: String) { + /** Returns the Platoon to its profile's server region; removes both scoped keys. */ + fun clearRegionOverride(context: Context, storageId: String) = + restoreScopedState(context, storageId, ScopedZoneState(region = null, zone = null)) + + /** Profile deletion leaves no timezone keys behind; identical to [clearRegionOverride]. */ + fun clearScope(context: Context, storageId: String) = clearRegionOverride(context, storageId) + + /** The raw scoped keys, so a failed settings replacement can restore them exactly. */ + internal data class ScopedZoneState(val region: String?, val zone: String?) + + internal fun scopedState(context: Context, storageId: String): ScopedZoneState { require(storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID) - check( - scoped(context).edit() - .remove(regionKey(storageId)) - .remove(zoneKey(storageId)) - .commit(), - ) { "Unable to restore automatic server selection" } + val preferences = scoped(context) + return ScopedZoneState( + region = preferences.getString(regionKey(storageId), null), + zone = preferences.getString(zoneKey(storageId), null), + ) } - fun clearScope(context: Context, storageId: String) { + /** Null for the legacy scope, whose timezone lives in the unified settings store. */ + internal fun scopedStateOrNull(context: Context, storageId: String): ScopedZoneState? = + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) null else scopedState(context, storageId) + + internal fun restoreScopedState(context: Context, storageId: String, state: ScopedZoneState) { require(storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID) - check( - scoped(context).edit() - .remove(regionKey(storageId)) - .remove(zoneKey(storageId)) - .commit(), - ) { "Unable to clear Platoon timezone settings" } + val editor = scoped(context).edit() + if (state.region == null) { + editor.remove(regionKey(storageId)) + } else { + editor.putString(regionKey(storageId), state.region) + } + if (state.zone == null) editor.remove(zoneKey(storageId)) else editor.putString(zoneKey(storageId), state.zone) + check(editor.commit()) { "Unable to update Platoon timezone settings" } } fun setRegion( diff --git a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt index 4ee16ef..f30aaf1 100644 --- a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt @@ -32,17 +32,15 @@ class MemberOrderPreferences( if (isLegacy) { UserSettingsPreferences.clearMemberOrder(appContext) } else { - scoped.edit().remove(storageId).apply() + check(scoped.edit().remove(storageId).commit()) { "Unable to clear the member order" } } } + /** Saved UIDs first in saved order; everyone else keeps their incoming order (stable sort). */ fun apply(items: List, uid: (T) -> Long): List { val positions = read().withIndex().associate { it.value to it.index } if (positions.isEmpty()) return items - return items.sortedWith( - compareBy { positions[uid(it)] ?: Int.MAX_VALUE } - .thenBy { items.indexOf(it) }, - ) + return items.sortedBy { positions[uid(it)] ?: Int.MAX_VALUE } } private val isLegacy: Boolean diff --git a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt index b1fcc26..bc02289 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -20,14 +20,6 @@ internal object UserSettingsPreferences { private const val KEY_DETAILED_NOTIFICATIONS = "detailed_notifications" private const val KEY_TARGET_PACKAGE = "target_package" private const val KEY_MEMBER_ORDER = "member_order" - private const val KEY_DAILY_MERIT = "cutline_daily_merit" - private const val KEY_DAILY_SCORE = "cutline_daily_gunsmoke_score" - private const val KEY_DAILY_ATTEMPTS = "cutline_daily_gunsmoke_attempts" - private const val KEY_WEEKLY_MERIT = "cutline_weekly_merit" - private const val KEY_WEEKLY_SCORE = "cutline_weekly_gunsmoke_score" - private const val KEY_WEEKLY_ATTEMPTS = "cutline_weekly_gunsmoke_attempts" - private const val KEY_WEEKLY_LOGIN_DAYS = "cutline_weekly_login_days" - private const val KEY_WEEKLY_PATROL_DAYS = "cutline_weekly_patrol_days" private val lock = Any() @@ -120,7 +112,7 @@ internal object UserSettingsPreferences { fun setMemberOrder(context: Context, uids: List): Boolean = synchronized(lock) { preferencesLocked(context.applicationContext).edit() - .putString(KEY_MEMBER_ORDER, uids.distinct().joinToString(",")) + .putString(KEY_MEMBER_ORDER, uids.filter { it > 0L }.distinct().joinToString(",")) .commit() } @@ -131,7 +123,7 @@ internal object UserSettingsPreferences { } fun setWeeklyCutlines(context: Context, cutlines: WeeklyCutlines) = edit(context) { - putCutlines(cutlines) + putWeeklyCutlines(cutlines, WeeklyCutlineField::settingsKey) } fun targetPackage(context: Context): String = synchronized(lock) { @@ -190,10 +182,9 @@ internal object UserSettingsPreferences { gameTimeZoneId = preferences.getString(KEY_GAME_TIME_ZONE, ZoneId.systemDefault().id).orEmpty(), onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false), detailedNotifications = preferences.getBoolean(KEY_DETAILED_NOTIFICATIONS, true), - targetPackage = preferences.getString( - KEY_TARGET_PACKAGE, - TargetPackagePreferences.DEFAULT_TARGET_PACKAGE, - ).orEmpty(), + // The target package is no longer chosen at runtime, so an unusable stored value (an + // unvalidated copy from the v2.2 store) falls back instead of failing every backup. + targetPackage = backupTargetPackage(preferences.getString(KEY_TARGET_PACKAGE, null)), payloadHistory = PayloadCatalog.categories.associate { category -> category.payloadType to (category.isRequired || preferences.getBoolean(payloadKey(category.payloadType), false)) @@ -217,7 +208,7 @@ internal object UserSettingsPreferences { val memberOrder = parseMemberOrder( context.getSharedPreferences("weekly_member_order", Context.MODE_PRIVATE) .safeString("uids"), - ).filter { it > 0L } + ) val legacyCutlines = readLegacyCutlines( context.getSharedPreferences("weekly_cutlines", Context.MODE_PRIVATE), ) @@ -226,8 +217,7 @@ internal object UserSettingsPreferences { Context.MODE_PRIVATE, ).safeString(TargetPackagePreferences.KEY_TARGET_PACKAGE) ?.trim() - ?.takeIf { TARGET_PACKAGE.matches(it) } - ?: TargetPackagePreferences.DEFAULT_TARGET_PACKAGE + .let(::backupTargetPackage) return AppBackupSettings( language = language, themeMode = "system", @@ -290,16 +280,8 @@ internal object UserSettingsPreferences { ) } - private fun readCutlines(preferences: SharedPreferences) = WeeklyCutlines( - dailyMerit = preferences.optionalLong(KEY_DAILY_MERIT), - dailyGunsmokeScore = preferences.optionalLong(KEY_DAILY_SCORE), - dailyGunsmokeAttempts = preferences.optionalInt(KEY_DAILY_ATTEMPTS), - weeklyMerit = preferences.optionalLong(KEY_WEEKLY_MERIT), - weeklyGunsmokeScore = preferences.optionalLong(KEY_WEEKLY_SCORE), - weeklyGunsmokeAttempts = preferences.optionalInt(KEY_WEEKLY_ATTEMPTS), - weeklyLoginDays = preferences.optionalInt(KEY_WEEKLY_LOGIN_DAYS), - weeklyPatrolDays = preferences.optionalInt(KEY_WEEKLY_PATROL_DAYS), - ) + private fun readCutlines(preferences: SharedPreferences) = + preferences.readWeeklyCutlines(WeeklyCutlineField::settingsKey) private fun writeSettings( editor: SharedPreferences.Editor, @@ -319,29 +301,7 @@ internal object UserSettingsPreferences { target.putBoolean(payloadKey(payloadType), enabled) } } - .putCutlines(settings.weeklyCutlines) - - private fun SharedPreferences.Editor.putCutlines(cutlines: WeeklyCutlines) = - putOptionalLong(KEY_DAILY_MERIT, cutlines.dailyMerit) - .putOptionalLong(KEY_DAILY_SCORE, cutlines.dailyGunsmokeScore) - .putOptionalInt(KEY_DAILY_ATTEMPTS, cutlines.dailyGunsmokeAttempts) - .putOptionalLong(KEY_WEEKLY_MERIT, cutlines.weeklyMerit) - .putOptionalLong(KEY_WEEKLY_SCORE, cutlines.weeklyGunsmokeScore) - .putOptionalInt(KEY_WEEKLY_ATTEMPTS, cutlines.weeklyGunsmokeAttempts) - .putOptionalInt(KEY_WEEKLY_LOGIN_DAYS, cutlines.weeklyLoginDays) - .putOptionalInt(KEY_WEEKLY_PATROL_DAYS, cutlines.weeklyPatrolDays) - - private fun SharedPreferences.Editor.putOptionalLong(key: String, value: Long?) = - if (value == null) remove(key) else putLong(key, value) - - private fun SharedPreferences.Editor.putOptionalInt(key: String, value: Int?) = - if (value == null) remove(key) else putInt(key, value) - - private fun SharedPreferences.optionalLong(key: String): Long? = - if (contains(key)) getLong(key, 0L) else null - - private fun SharedPreferences.optionalInt(key: String): Int? = - if (contains(key)) getInt(key, 0) else null + .putWeeklyCutlines(settings.weeklyCutlines, WeeklyCutlineField::settingsKey) private fun SharedPreferences.safeString(key: String): String? = runCatching { getString(key, null) }.getOrNull() @@ -355,12 +315,19 @@ internal object UserSettingsPreferences { private fun SharedPreferences.safeInt(key: String): Int? = runCatching { getInt(key, 0) }.getOrNull().takeIf { contains(key) } + /** Only positive UIDs are member identities; anything else would fail settings validation. */ private fun parseMemberOrder(value: String?): List = value.orEmpty() .split(',') .mapNotNull(String::toLongOrNull) + .filter { it > 0L } .distinct() private fun payloadKey(payloadType: Int) = "payload_$payloadType" + /** [value] when the complete-backup codec accepts it, otherwise the default package. */ + private fun backupTargetPackage(value: String?): String = value + ?.takeIf { it.length in 3..255 && TARGET_PACKAGE.matches(it) } + ?: TargetPackagePreferences.DEFAULT_TARGET_PACKAGE + private val TARGET_PACKAGE = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+") } diff --git a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt new file mode 100644 index 0000000..403e5d7 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt @@ -0,0 +1,105 @@ +package dev.gf2log.app.settings + +import android.content.SharedPreferences + +/** + * One weekly cutline and every persisted spelling of it. The backup, unified-settings, and + * per-Platoon keys predate this table and must never change, and each field keeps the + * SharedPreferences value type it was first written with. + */ +internal enum class WeeklyCutlineField( + val backupKey: String, + val settingsKey: String, + val scopedKey: String, + val storedAsInt: Boolean, + val range: LongRange, +) { + DAILY_MERIT("cutline.dailyMerit", "cutline_daily_merit", "daily_merit", false, 0..Long.MAX_VALUE), + DAILY_SCORE( + "cutline.dailyGunsmokeScore", + "cutline_daily_gunsmoke_score", + "daily_score", + false, + 0..Long.MAX_VALUE, + ), + DAILY_ATTEMPTS( + "cutline.dailyGunsmokeAttempts", + "cutline_daily_gunsmoke_attempts", + "daily_attempts", + true, + 0L..3L, + ), + WEEKLY_MERIT("cutline.weeklyMerit", "cutline_weekly_merit", "weekly_merit", false, 0..Long.MAX_VALUE), + WEEKLY_SCORE( + "cutline.weeklyGunsmokeScore", + "cutline_weekly_gunsmoke_score", + "weekly_score", + false, + 0..Long.MAX_VALUE, + ), + WEEKLY_ATTEMPTS( + "cutline.weeklyGunsmokeAttempts", + "cutline_weekly_gunsmoke_attempts", + "weekly_attempts", + true, + 0L..21L, + ), + WEEKLY_LOGIN_DAYS("cutline.weeklyLoginDays", "cutline_weekly_login_days", "weekly_login", true, 0L..7L), + WEEKLY_PATROL_DAYS( + "cutline.weeklyPatrolDays", + "cutline_weekly_patrol_days", + "weekly_patrol", + true, + 0L..7L, + ), + ; + + fun valueIn(cutlines: WeeklyCutlines): Long? = when (this) { + DAILY_MERIT -> cutlines.dailyMerit + DAILY_SCORE -> cutlines.dailyGunsmokeScore + DAILY_ATTEMPTS -> cutlines.dailyGunsmokeAttempts?.toLong() + WEEKLY_MERIT -> cutlines.weeklyMerit + WEEKLY_SCORE -> cutlines.weeklyGunsmokeScore + WEEKLY_ATTEMPTS -> cutlines.weeklyGunsmokeAttempts?.toLong() + WEEKLY_LOGIN_DAYS -> cutlines.weeklyLoginDays?.toLong() + WEEKLY_PATROL_DAYS -> cutlines.weeklyPatrolDays?.toLong() + } + + companion object { + fun cutlinesOf(value: (WeeklyCutlineField) -> Long?): WeeklyCutlines = WeeklyCutlines( + dailyMerit = value(DAILY_MERIT), + dailyGunsmokeScore = value(DAILY_SCORE), + dailyGunsmokeAttempts = value(DAILY_ATTEMPTS)?.toInt(), + weeklyMerit = value(WEEKLY_MERIT), + weeklyGunsmokeScore = value(WEEKLY_SCORE), + weeklyGunsmokeAttempts = value(WEEKLY_ATTEMPTS)?.toInt(), + weeklyLoginDays = value(WEEKLY_LOGIN_DAYS)?.toInt(), + weeklyPatrolDays = value(WEEKLY_PATROL_DAYS)?.toInt(), + ) + } +} + +internal fun SharedPreferences.readWeeklyCutlines(key: (WeeklyCutlineField) -> String): WeeklyCutlines = + WeeklyCutlineField.cutlinesOf { field -> + val stored = key(field) + when { + !contains(stored) -> null + field.storedAsInt -> getInt(stored, 0).toLong() + else -> getLong(stored, 0L) + } + } + +internal fun SharedPreferences.Editor.putWeeklyCutlines( + cutlines: WeeklyCutlines, + key: (WeeklyCutlineField) -> String, +): SharedPreferences.Editor = apply { + WeeklyCutlineField.entries.forEach { field -> + val stored = key(field) + val value = field.valueIn(cutlines) + when { + value == null -> remove(stored) + field.storedAsInt -> putInt(stored, value.toInt()) + else -> putLong(stored, value) + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt index 97bd262..6905863 100644 --- a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt @@ -49,16 +49,7 @@ class WeeklyCutlinePreferences( fun read(): WeeklyCutlines = if (isLegacy) { UserSettingsPreferences.weeklyCutlines(appContext) } else { - WeeklyCutlines( - dailyMerit = scoped.optionalLong(key(DAILY_MERIT)), - dailyGunsmokeScore = scoped.optionalLong(key(DAILY_SCORE)), - dailyGunsmokeAttempts = scoped.optionalInt(key(DAILY_ATTEMPTS)), - weeklyMerit = scoped.optionalLong(key(WEEKLY_MERIT)), - weeklyGunsmokeScore = scoped.optionalLong(key(WEEKLY_SCORE)), - weeklyGunsmokeAttempts = scoped.optionalInt(key(WEEKLY_ATTEMPTS)), - weeklyLoginDays = scoped.optionalInt(key(WEEKLY_LOGIN)), - weeklyPatrolDays = scoped.optionalInt(key(WEEKLY_PATROL)), - ) + scoped.readWeeklyCutlines(::key) } fun write(cutlines: WeeklyCutlines) { @@ -66,16 +57,9 @@ class WeeklyCutlinePreferences( UserSettingsPreferences.setWeeklyCutlines(appContext, cutlines) return } - val editor = scoped.edit() - editor.putOptionalLong(key(DAILY_MERIT), cutlines.dailyMerit) - editor.putOptionalLong(key(DAILY_SCORE), cutlines.dailyGunsmokeScore) - editor.putOptionalInt(key(DAILY_ATTEMPTS), cutlines.dailyGunsmokeAttempts) - editor.putOptionalLong(key(WEEKLY_MERIT), cutlines.weeklyMerit) - editor.putOptionalLong(key(WEEKLY_SCORE), cutlines.weeklyGunsmokeScore) - editor.putOptionalInt(key(WEEKLY_ATTEMPTS), cutlines.weeklyGunsmokeAttempts) - editor.putOptionalInt(key(WEEKLY_LOGIN), cutlines.weeklyLoginDays) - editor.putOptionalInt(key(WEEKLY_PATROL), cutlines.weeklyPatrolDays) - check(editor.commit()) { "Unable to persist weekly cutlines" } + check(scoped.edit().putWeeklyCutlines(cutlines, ::key).commit()) { + "Unable to persist weekly cutlines" + } } fun clear() { @@ -83,48 +67,17 @@ class WeeklyCutlinePreferences( write(WeeklyCutlines()) return } - val editor = scoped.edit() - listOf( - DAILY_MERIT, - DAILY_SCORE, - DAILY_ATTEMPTS, - WEEKLY_MERIT, - WEEKLY_SCORE, - WEEKLY_ATTEMPTS, - WEEKLY_LOGIN, - WEEKLY_PATROL, - ).forEach { editor.remove(key(it)) } - check(editor.commit()) { "Unable to clear weekly cutlines" } + check(scoped.edit().putWeeklyCutlines(WeeklyCutlines(), ::key).commit()) { + "Unable to clear weekly cutlines" + } } private val isLegacy: Boolean get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID - private fun key(name: String) = "$storageId.$name" - - private fun android.content.SharedPreferences.optionalLong(key: String): Long? = - if (contains(key)) getLong(key, 0L) else null - - private fun android.content.SharedPreferences.optionalInt(key: String): Int? = - if (contains(key)) getInt(key, 0) else null - - private fun android.content.SharedPreferences.Editor.putOptionalLong(key: String, value: Long?) { - if (value == null) remove(key) else putLong(key, value) - } - - private fun android.content.SharedPreferences.Editor.putOptionalInt(key: String, value: Int?) { - if (value == null) remove(key) else putInt(key, value) - } + private fun key(field: WeeklyCutlineField) = "$storageId.${field.scopedKey}" private companion object { const val PROFILE_PREFERENCES = "platoon_weekly_cutlines" - const val DAILY_MERIT = "daily_merit" - const val DAILY_SCORE = "daily_score" - const val DAILY_ATTEMPTS = "daily_attempts" - const val WEEKLY_MERIT = "weekly_merit" - const val WEEKLY_SCORE = "weekly_score" - const val WEEKLY_ATTEMPTS = "weekly_attempts" - const val WEEKLY_LOGIN = "weekly_login" - const val WEEKLY_PATROL = "weekly_patrol" } } diff --git a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt new file mode 100644 index 0000000..0ffd672 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt @@ -0,0 +1,65 @@ +package dev.gf2log.app.ui + +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Serialized off-main-thread work bound to one screen's lifecycle. + * + * Every task runs on one worker so repository calls from a screen keep their submission order. + * Results are delivered on the main thread only while the owner is not destroyed, and a keyed + * request supersedes earlier requests with the same key, so a stale load can never overwrite a + * newer one. Running tasks are allowed to finish after destruction because they may be committing + * data; only their results are dropped. + */ +internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { + private val main = Handler(Looper.getMainLooper()) + private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, threadName) + } + private val generations = HashMap() + private val running = HashMap() + private var destroyed = false + + init { + owner.lifecycle.addObserver(LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) { + destroyed = true + executor.shutdown() + } + }) + } + + /** True while a request for [key] has been submitted and its result is not yet delivered. */ + fun isBusy(key: String): Boolean = (running[key] ?: 0) > 0 + + fun run( + key: String? = null, + task: () -> T, + onResult: (Result) -> Unit, + ) { + if (destroyed) return + val generation = key?.let { generations[it] = (generations[it] ?: 0) + 1; generations[it] } + key?.let { running[it] = (running[it] ?: 0) + 1 } + executor.execute { + val result = runCatching(task) + result.exceptionOrNull()?.let { Log.w(TAG, "Background task failed", it) } + main.post { + key?.let { running[it] = ((running[it] ?: 1) - 1).coerceAtLeast(0) } + if (destroyed) return@post + if (key != null && generations[key] != generation) return@post + onResult(result) + } + } + } + + private companion object { + const val TAG = "GF2Work" + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt new file mode 100644 index 0000000..94e7ec5 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import dev.gf2log.app.R +import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.capture.CaptureVpnService + +/** True from the moment capture is requested until it stops; risky data operations wait. */ +internal val captureActive: Boolean + get() = CaptureStatus.snapshot().phase != CapturePhase.STOPPED + +/** + * Data operations that must not interleave with capture ask once and stop it in place, instead of + * sending the user back to Home to find the Stop button. + */ +internal object CaptureGuard { + fun stop(context: Context) { + context.startService(Intent(context, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP)) + CaptureStatus.markStopped() + } + + /** Runs [action] now, or after the user agrees to stop the running capture; [reason] says why. */ + fun whenStopped(activity: Activity, @StringRes reason: Int, action: () -> Unit) { + if (!captureActive) { + action() + return + } + Dialogs.confirm(activity, R.string.capture_running_title, reason, R.string.stop_capture_and_continue) { + stop(activity) + action() + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Controls.kt b/app/src/main/java/dev/gf2log/app/ui/Controls.kt new file mode 100644 index 0000000..59a7784 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Controls.kt @@ -0,0 +1,241 @@ +package dev.gf2log.app.ui + +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import androidx.annotation.ColorRes +import dev.gf2log.app.R + +/** Semantic control styling from docs/UI_DESIGN_SYSTEM.md; screens choose roles, never colors. */ +internal object Controls { + enum class Role { + /** The single commitment action of a screen or panel. */ + PRIMARY, + + /** Starts continuous capture; green readiness treatment. */ + CAPTURE, + SECONDARY, + + /** Low-emphasis inline action such as Select or Show all. */ + TERTIARY, + DESTRUCTIVE, + DESTRUCTIVE_TEXT, + } + + private data class Palette( + @param:ColorRes val fill: Int, + @param:ColorRes val pressed: Int, + @param:ColorRes val stroke: Int, + @param:ColorRes val text: Int, + ) + + private fun palette(role: Role): Palette = when (role) { + Role.PRIMARY -> Palette( + R.color.primary_action_background, + R.color.primary_action_pressed, + android.R.color.transparent, + R.color.primary_action_foreground, + ) + Role.CAPTURE -> Palette(R.color.success_surface, R.color.success_pressed, R.color.success_text, R.color.success_text) + Role.SECONDARY -> Palette(R.color.surface_variant, R.color.surface_pressed, android.R.color.transparent, R.color.text_primary) + Role.TERTIARY -> Palette(android.R.color.transparent, R.color.surface_pressed, android.R.color.transparent, R.color.accent_text) + Role.DESTRUCTIVE -> Palette( + R.color.destructive_action_background, + R.color.destructive_action_pressed, + android.R.color.transparent, + R.color.destructive_action_foreground, + ) + Role.DESTRUCTIVE_TEXT -> Palette( + android.R.color.transparent, + R.color.surface_pressed, + android.R.color.transparent, + R.color.destructive_action, + ) + } + + /** A 48dp touch target around a 40dp visible surface with explicit disabled state. */ + fun style(button: Button, role: Role) { + val context = button.context + val colors = palette(role) + button.isAllCaps = false + button.elevation = 0f + button.stateListAnimator = null + button.minHeight = context.dp(48) + button.minWidth = context.dp(48) + button.textSize = 14f + button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + button.maxLines = 2 + button.ellipsize = TextUtils.TruncateAt.END + button.gravity = Gravity.CENTER + button.setPaddingRelative(context.dp(16), 0, context.dp(16), 0) + button.setTextColor( + ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + intArrayOf(context.getColor(R.color.text_secondary), context.getColor(colors.text)), + ), + ) + button.backgroundTintList = null + button.background = InsetDrawable( + StateListDrawable().apply { + val disabledFill = if (colors.fill == android.R.color.transparent) colors.fill else R.color.surface_variant + addState(intArrayOf(-android.R.attr.state_enabled), shape(context, disabledFill, android.R.color.transparent)) + addState(intArrayOf(android.R.attr.state_pressed), shape(context, colors.pressed, colors.stroke)) + addState(intArrayOf(), shape(context, colors.fill, colors.stroke)) + }, + 0, + context.dp(4), + 0, + context.dp(4), + ) + button.refreshDrawableState() + button.jumpDrawablesToCurrentState() + } + + private fun shape(context: Context, @ColorRes fill: Int, @ColorRes stroke: Int): Drawable = GradientDrawable().apply { + setColor(context.getColor(fill)) + cornerRadius = context.dpf(12f) + if (stroke != android.R.color.transparent) setStroke(context.dp(1), context.getColor(stroke)) + } + + fun controlTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.outline_strong), + context.getColor(R.color.accent_text), + context.getColor(R.color.outline_strong), + ), + ) + + fun switchThumbTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + // An off thumb in outline ink stays visible on dark dialogs as well as light panels. + intArrayOf( + context.getColor(R.color.outline), + context.getColor(R.color.accent_text), + context.getColor(R.color.outline_strong), + ), + ) + + fun switchTrackTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.outline), + context.getColor(R.color.accent_surface_pressed), + context.getColor(R.color.outline_strong), + ), + ) + + /** + * Mutually exclusive choices on one 56dp rail: a 48dp accent thumb glides between equal-width + * native radio buttons, and motion is skipped when system animations are off. + */ + fun segmentedControl( + context: Context, + options: List>, + selectedValue: String, + onSelected: (String) -> Unit, + ): View { + require(options.isNotEmpty()) { "A segmented control requires at least one option" } + val segmentHeight = context.dp(48) + val selection = View(context).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + background = GradientDrawable().apply { + setColor(context.getColor(R.color.accent_surface)) + cornerRadius = context.dpf(12f) + } + } + val choices = RadioGroup(context).apply { orientation = RadioGroup.HORIZONTAL } + val rail = FrameLayout(context).apply { + val inset = context.dp(4) + setPadding(inset, inset, inset, inset) + background = GradientDrawable().apply { + setColor(context.getColor(R.color.surface_variant)) + cornerRadius = context.dpf(16f) + } + addView(selection, FrameLayout.LayoutParams(0, segmentHeight)) + addView(choices, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, segmentHeight)) + } + var currentValue = selectedValue + + fun moveSelection(index: Int, animated: Boolean, finished: (() -> Unit)? = null) { + val segment = choices.getChildAt(index) + // Not laid out yet: the rail's layout listener places the thumb once widths exist. + if (segment == null || segment.width <= 0) return + selection.layoutParams = (selection.layoutParams as FrameLayout.LayoutParams).apply { width = segment.width } + val target = segment.left.toFloat() + selection.animate().cancel() + if (animated && ValueAnimator.areAnimatorsEnabled()) { + selection.animate().translationX(target).setDuration(160L).withEndAction { finished?.invoke() }.start() + } else { + selection.translationX = target + finished?.invoke() + } + } + + val textColors = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(context.getColor(R.color.accent_text), context.getColor(R.color.text_primary)), + ) + options.forEachIndexed { index, (value, label) -> + choices.addView(RadioButton(context).apply { + id = View.generateViewId() + text = label + textSize = 14f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + buttonDrawable = null + background = RippleDrawable( + ColorStateList.valueOf(context.getColor(R.color.accent_surface_pressed)), + null, + GradientDrawable().apply { + setColor(context.getColor(android.R.color.white)) + cornerRadius = context.dpf(12f) + }, + ) + setTextColor(textColors) + isChecked = value == selectedValue + minHeight = segmentHeight + minWidth = context.dp(48) + setPadding(context.dp(4), 0, context.dp(4), 0) + setOnClickListener { + if (value == currentValue) return@setOnClickListener + currentValue = value + moveSelection(index, animated = true) + onSelected(value) + } + }, RadioGroup.LayoutParams(0, segmentHeight, 1f)) + } + rail.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> + if (right - left == oldRight - oldLeft) return@addOnLayoutChangeListener + moveSelection(options.indexOfFirst { it.first == currentValue }.coerceAtLeast(0), animated = false) + } + return rail + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt new file mode 100644 index 0000000..138a854 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -0,0 +1,163 @@ +package dev.gf2log.app.ui + +import android.app.AlertDialog +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Toast +import androidx.annotation.StringRes +import dev.gf2log.app.R + +/** App-owned dialogs: one surface, one accent, and an explicit red treatment for destruction. */ +internal object Dialogs { + fun builder(context: Context): AlertDialog.Builder = AlertDialog.Builder(context) + + /** Shows the dialog and applies the shared button palette once its buttons exist. */ + fun show( + builder: AlertDialog.Builder, + destructive: Boolean = false, + onShown: (AlertDialog) -> Unit = {}, + ): AlertDialog { + val dialog = builder.create() + dialog.setOnShowListener { + val context = dialog.context + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply { + setTextColor(context.getColor(if (destructive) R.color.destructive_action else R.color.accent_text)) + isAllCaps = false + } + listOf(AlertDialog.BUTTON_NEGATIVE, AlertDialog.BUTTON_NEUTRAL).forEach { which -> + dialog.getButton(which)?.apply { + setTextColor(context.getColor(R.color.text_secondary)) + isAllCaps = false + } + } + onShown(dialog) + } + return showTracked(dialog) + } + + /** + * Shows a platform dialog that is not built through [builder], such as a date or time picker, + * and registers it with its screen so [dismissAll] closes it on recreation. + */ + fun showTracked(dialog: D): D { + val owner = owner(dialog.context) + // Background results can arrive after finish(); a leaving screen gets no new window. + if (owner != null && (owner.isFinishing || owner.isDestroyed)) return dialog + dialog.show() + owner?.let { open.getOrPut(it) { mutableListOf() }.add(java.lang.ref.WeakReference(dialog)) } + return dialog + } + + private val open = java.util.WeakHashMap>>() + + private tailrec fun owner(context: Context): android.app.Activity? = when (context) { + is android.app.Activity -> context + is android.content.ContextWrapper -> owner(context.baseContext) + else -> null + } + + /** Closes every dialog a screen opened so a recreated screen never leaks its windows. */ + fun dismissAll(owner: android.app.Activity) { + open.remove(owner)?.forEach { it.get()?.takeIf(android.app.Dialog::isShowing)?.dismiss() } + } + + fun confirm( + context: Context, + title: CharSequence, + message: CharSequence?, + confirmLabel: CharSequence, + destructive: Boolean = false, + onConfirm: () -> Unit, + ): AlertDialog = show( + builder(context) + .setTitle(title) + .setMessage(message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(confirmLabel) { _, _ -> onConfirm() }, + destructive = destructive, + ) + + fun confirm( + context: Context, + @StringRes title: Int, + @StringRes message: Int, + @StringRes confirmLabel: Int, + destructive: Boolean = false, + onConfirm: () -> Unit, + ): AlertDialog = confirm( + context, + context.getString(title), + context.getString(message), + context.getString(confirmLabel), + destructive, + onConfirm, + ) + + fun choose( + context: Context, + title: CharSequence, + labels: List, + checked: Int, + onChosen: (Int) -> Unit, + ): AlertDialog = show( + builder(context) + .setTitle(title) + .setSingleChoiceItems(labels.toTypedArray(), checked) { dialog, which -> + dialog.dismiss() + onChosen(which) + } + .setNegativeButton(R.string.cancel, null), + ) + + fun message(context: Context, title: CharSequence, message: CharSequence): AlertDialog = show( + builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null), + ) + + /** Padded, scrollable dialog body aligned with the platform title inset. */ + fun content(context: Context, block: LinearLayout.() -> Unit): View = ScrollView(context).apply { + addView(context.column { + setPadding(context.dp(24), context.dp(8), context.dp(24), context.dp(8)) + block() + }) + } +} + +/** + * Replaces the visible transient message instead of extending Android's Toast queue, so rapid + * repeated actions keep their feedback current. + */ +internal object Feedback { + private var active: Toast? = null + + private val main = android.os.Handler(android.os.Looper.getMainLooper()) + + fun show(context: Context, message: CharSequence, long: Boolean = false) { + if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { + val appContext = context.applicationContext + main.post { show(appContext, message, long) } + return + } + active?.cancel() + active = Toast.makeText( + context.applicationContext, + message, + if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT, + ).also(Toast::show) + } + + fun show(context: Context, @StringRes message: Int, long: Boolean = false) { + show(context, context.getString(message), long) + } + + /** Copies plain [value] under the clipboard [label] and confirms with [confirmation]. */ + fun copy(context: Context, label: CharSequence, value: CharSequence, @StringRes confirmation: Int) { + context.getSystemService(android.content.ClipboardManager::class.java) + .setPrimaryClip(android.content.ClipData.newPlainText(label, value)) + show(context, confirmation) + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt new file mode 100644 index 0000000..9198472 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt @@ -0,0 +1,61 @@ +package dev.gf2log.app.ui + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Paths + +/** + * Opens only Storage Access Framework documents from the platform and Google document providers. + * A user-selected destination can never resolve into another app's private storage. + */ +internal object DocumentAccess { + private val TRUSTED_AUTHORITIES = setOf( + "com.android.externalstorage.documents", + "com.android.providers.downloads.documents", + "com.android.providers.media.documents", + "com.google.android.apps.docs.storage", + "com.google.android.apps.nbu.files.provider", + ) + + fun openOutput(resolver: ContentResolver, destination: Uri): OutputStream { + requireTrusted(destination) + val path = destination.path ?: throw SecurityException("Export URI has no path") + if (Paths.get(path).normalize().startsWith("/data")) { + throw SecurityException("Export URI targets private app storage") + } + // Truncate so an overwritten document cannot keep stale trailing bytes; providers that + // reject "wt" create a fresh document anyway, so plain "w" is the safe fallback. + val output = try { + resolver.openOutputStream(destination, "wt") + } catch (_: IllegalArgumentException) { + resolver.openOutputStream(destination, "w") + } catch (_: java.io.FileNotFoundException) { + resolver.openOutputStream(destination, "w") + } + return output ?: error("Document provider did not open an output stream") + } + + fun openInput(resolver: ContentResolver, source: Uri): InputStream { + requireTrusted(source) + return resolver.openInputStream(source) + ?: error("Document provider did not open an input stream") + } + + fun displayName(resolver: ContentResolver, uri: Uri): String? = runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() + + private fun requireTrusted(uri: Uri) { + if (uri.scheme != ContentResolver.SCHEME_CONTENT) { + throw SecurityException("Unsupported document URI scheme") + } + if (uri.authority !in TRUSTED_AUTHORITIES) { + throw SecurityException("Untrusted document provider") + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Labels.kt b/app/src/main/java/dev/gf2log/app/ui/Labels.kt new file mode 100644 index 0000000..8f91e18 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -0,0 +1,110 @@ +package dev.gf2log.app.ui + +import android.content.Context +import dev.gf2log.app.R +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.PayloadCatalog +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.TemporalAccessor + +/** Localized presentation labels shared by every screen; no domain policy lives here. */ +internal object Labels { + val DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yy/MM/dd") + val DATE_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm") + val DATE_TIME_SECONDS: DateTimeFormatter = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") + val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + val FILE_STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") + val FILE_DATE: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + + /** "#1234": member UIDs read the same everywhere and stay out of string concatenation. */ + fun uid(context: Context, uid: Long): String = context.getString(R.string.member_uid, uid) + + /** "Label: value" for spoken descriptions, with locale-controlled punctuation. */ + fun labeled(context: Context, label: CharSequence, value: CharSequence): String = + context.getString(R.string.labeled_value, label, value) + + fun format(formatter: DateTimeFormatter, instant: Instant, zone: ZoneId): String = + formatter.format(instant.atZone(zone)) + + fun format(formatter: DateTimeFormatter, value: TemporalAccessor): String = formatter.format(value) + + /** "10/12 Sun" with the weekday in the active app language. */ + fun dayHeader(context: Context, day: java.time.LocalDate): String = + "${day.monthValue}/${day.dayOfMonth} " + + day.dayOfWeek.getDisplayName(TextStyle.SHORT, context.resources.configuration.locales[0]) + + fun region(context: Context, region: GameServerRegion): String = context.getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + + /** Server name without the client prefix, for places where the client is already shown. */ + fun regionShort(context: Context, region: GameServerRegion): String = context.getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL, GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_short_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_short_china + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_short_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_short_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_short_asia + }, + ) + + /** "HaoPlay · South Korea · ID 1234" */ + fun profileIdentity(context: Context, profile: PlatoonProfile): String = context.getString( + R.string.profile_identity, + profile.client.displayName, + regionShort(context, profile.serverRegion), + profile.platoonId, + ) + + fun payloadName(context: Context, payloadType: Int?): String = when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> context.getString(R.string.payload_name_platoon_profile) + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> context.getString(R.string.payload_name_platoon_members) + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> context.getString(R.string.payload_name_platoon_activity) + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> context.getString(R.string.payload_name_platoon_updates) + Gfl2PayloadDecoder.TYPE_WEAPONS -> context.getString(R.string.payload_name_weapons) + Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> context.getString(R.string.payload_name_attachments) + Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> context.getString(R.string.payload_name_common_keys) + Gfl2PayloadDecoder.TYPE_FORMATIONS -> context.getString(R.string.payload_name_formations) + else -> context.getString(R.string.unknown_payload_type) + } + + fun payloadTag(context: Context, payloadType: Int?): String = when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> context.getString(R.string.payload_tag_profile) + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> context.getString(R.string.payload_tag_platoon) + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> context.getString(R.string.payload_tag_activity) + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> context.getString(R.string.payload_tag_updates) + Gfl2PayloadDecoder.TYPE_WEAPONS -> context.getString(R.string.payload_tag_weapons) + Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> context.getString(R.string.payload_tag_attachments) + Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> context.getString(R.string.payload_tag_common_keys) + Gfl2PayloadDecoder.TYPE_FORMATIONS -> context.getString(R.string.payload_tag_formations) + else -> PayloadCatalog.tag(payloadType) + } + + fun payloadDescription(context: Context, payloadType: Int): String = context.getString( + when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> R.string.payload_description_platoon_profile + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_description_platoon_members + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> R.string.payload_description_platoon_activity + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> R.string.payload_description_platoon_updates + Gfl2PayloadDecoder.TYPE_WEAPONS -> R.string.payload_description_weapons + Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> R.string.payload_description_attachments + Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> R.string.payload_description_common_keys + Gfl2PayloadDecoder.TYPE_FORMATIONS -> R.string.payload_description_formations + else -> R.string.unknown_payload_type + }, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Screen.kt b/app/src/main/java/dev/gf2log/app/ui/Screen.kt new file mode 100644 index 0000000..5d331c2 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Screen.kt @@ -0,0 +1,103 @@ +package dev.gf2log.app.ui + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.ScrollView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.PrimaryNavigation +import dev.gf2log.app.R + +/** + * Edge-to-edge screen shell: top bar, replaceable body, and the optional three-destination + * bottom bar. Window insets are applied here once so no Activity handles status, navigation, + * cutout, or keyboard insets on its own. + */ +internal class Screen( + private val activity: Activity, + destination: PrimaryNavigation.Destination? = null, +) { + val topBar = TopBar(activity) + private val content = FrameLayout(activity) + private val bottomBar = destination?.let { PrimaryNavigation.bar(activity, it) } + private var bottomInset = 0 + private var body: View? = null + + val root: LinearLayout = activity.column { + setBackgroundColor(activity.getColor(R.color.app_background)) + addView(topBar, activity.lp()) + addView(content, activity.lp(height = 0, weight = 1f)) + bottomBar?.let { + addView(activity.divider()) + addView(it, activity.lp()) + } + } + + init { + ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> + val bars = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + val ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + view.setPadding(bars.left, 0, bars.right, 0) + topBar.setPadding(topBar.paddingLeft, bars.top, topBar.paddingRight, 0) + bottomInset = if (bottomBar != null) { + bottomBar.setPadding(0, 0, 0, bars.bottom) + maxOf(0, ime.bottom - bars.bottom) + } else { + maxOf(bars.bottom, ime.bottom) + } + applyBottomInset() + WindowInsetsCompat.CONSUMED + } + activity.setContentView(root) + } + + /** Blocks leaving through the bottom bar, e.g. while an unsaved edit is open. */ + fun setNavigationEnabled(enabled: Boolean) { + val bar = bottomBar as? android.view.ViewGroup ?: return + for (index in 0 until bar.childCount) { + bar.getChildAt(index).isEnabled = enabled + } + bar.alpha = if (enabled) 1f else 0.5f + } + + /** Replaces the body; scrolling bodies draw beneath the navigation bar and pad their end. */ + fun setBody(view: View) { + content.removeAllViews() + body = view + content.addView(view, FrameLayout.LayoutParams(MATCH, MATCH)) + applyBottomInset() + } + + private fun applyBottomInset() { + val target = body ?: return + val scrolls = target is ScrollView || target is RecyclerView || target is HorizontalScrollView + val padded = if (scrolls) target else content + val base = (padded.getTag(R.id.gf2_base_bottom_padding) as? Int) + ?: padded.paddingBottom.also { padded.setTag(R.id.gf2_base_bottom_padding, it) } + padded.setPadding(padded.paddingLeft, padded.paddingTop, padded.paddingRight, base + bottomInset) + padded.clipToPadding = false + } + + companion object { + /** Standard vertically scrolling body with the 16dp screen inset from DESIGN.md. */ + fun scrollingBody(activity: Activity, column: LinearLayout): ScrollView = + ScrollView(activity).apply { + id = R.id.screen_scroll + isFillViewport = true + column.setPadding( + activity.dp(16), + activity.dp(4), + activity.dp(16), + activity.dp(24), + ) + addView(column, FrameLayout.LayoutParams(MATCH, WRAP)) + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt b/app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt new file mode 100644 index 0000000..8ed2191 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt @@ -0,0 +1,44 @@ +package dev.gf2log.app.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView + +/** + * Hosts one pre-built view (a header, footer, or state message) inside a ConcatAdapter. The view + * is never recycled, so focused inputs inside it survive list updates. + */ +internal class SingleViewAdapter(view: View? = null) : RecyclerView.Adapter() { + var view: View? = view + set(value) { + if (field === value) return + val hadView = field != null + field = value + when { + hadView && value != null -> notifyItemChanged(0) + hadView -> notifyItemRemoved(0) + value != null -> notifyItemInserted(0) + } + } + + override fun getItemCount(): Int = if (view == null) 0 else 1 + + override fun getItemViewType(position: Int): Int = System.identityHashCode(this) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + object : RecyclerView.ViewHolder( + FrameLayout(parent.context).apply { + layoutParams = RecyclerView.LayoutParams(MATCH, WRAP) + }, + ) {}.apply { setIsRecyclable(false) } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val container = holder.itemView as FrameLayout + val content = view ?: return + if (content.parent === container) return + (content.parent as? ViewGroup)?.removeView(content) + container.removeAllViews() + container.addView(content, FrameLayout.LayoutParams(MATCH, WRAP)) + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/StateViews.kt b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt new file mode 100644 index 0000000..afc8d4d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt @@ -0,0 +1,63 @@ +package dev.gf2log.app.ui + +import android.content.Context +import android.view.Gravity +import android.view.View +import android.widget.ProgressBar +import androidx.annotation.DrawableRes +import dev.gf2log.app.R + +/** Loading, empty, and failure states share one centered composition on every screen. */ +internal object StateViews { + fun loading(context: Context, message: CharSequence): View = context.column { + gravity = Gravity.CENTER_HORIZONTAL + setPadding(context.dp(24), context.dp(48), context.dp(24), context.dp(48)) + addView(ProgressBar(context).apply { + isIndeterminate = true + indeterminateTintList = android.content.res.ColorStateList.valueOf(context.getColor(R.color.accent)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, context.lp(36, 36)) + addView(context.text(message, TextRole.SECONDARY) { + gravity = Gravity.CENTER + setPadding(0, context.dp(12), 0, 0) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + }, context.lp()) + } + + fun empty( + context: Context, + @DrawableRes icon: Int, + title: CharSequence, + detail: CharSequence? = null, + actionLabel: CharSequence? = null, + onAction: (() -> Unit)? = null, + ): View = context.column { + gravity = Gravity.CENTER_HORIZONTAL + setPadding(context.dp(24), context.dp(40), context.dp(24), context.dp(40)) + addView(context.icon(icon, R.color.outline_strong, sizeDp = 40), context.lp(40, 40)) + addView(context.text(title, TextRole.SUBTITLE) { + gravity = Gravity.CENTER + setPadding(0, context.dp(12), 0, 0) + }, context.lp()) + detail?.let { + addView(context.text(it, TextRole.SECONDARY) { + gravity = Gravity.CENTER + setPadding(0, context.dp(6), 0, 0) + }, context.lp()) + } + if (actionLabel != null && onAction != null) { + addView( + context.button(actionLabel, Controls.Role.SECONDARY) { onAction() }, + context.lp(WRAP, WRAP, top = 16), + ) + } + } + + fun failure(context: Context, message: CharSequence, onRetry: () -> Unit): View = empty( + context, + R.drawable.ic_warning, + message, + actionLabel = context.getString(R.string.retry), + onAction = onRetry, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/ui/TopBar.kt b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt new file mode 100644 index 0000000..3efb873 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt @@ -0,0 +1,115 @@ +package dev.gf2log.app.ui + +import android.content.Context +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.PopupMenu +import androidx.annotation.DrawableRes +import dev.gf2log.app.R + +/** One trailing top-bar control. Icon-only actions always carry an accessible label. */ +internal data class BarAction( + @DrawableRes val icon: Int, + val label: CharSequence, + val enabled: Boolean = true, + val onClick: (View) -> Unit, +) + +/** One overflow-menu entry; overflow keeps secondary utilities quiet. */ +internal data class MenuEntry( + val label: CharSequence, + val enabled: Boolean = true, + val onClick: () -> Unit, +) + +/** + * Compact app bar shared by every screen. The status-bar inset is applied by [Screen]; this view + * owns only its 56dp content row. + */ +internal class TopBar(context: Context) : LinearLayout(context) { + private val navigation = context.iconButton(R.drawable.ic_arrow_back, context.getString(R.string.back)) {} + private val leading = context.frame() + private val titleView = context.text(null, TextRole.TITLE) { + textSize = 20f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + accessibilityHeading() + } + private val subtitleView = context.text(null, TextRole.CAPTION) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + visibility = View.GONE + } + private val actions = context.row() + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = context.dp(56) + setPadding(context.dp(4), 0, context.dp(4), 0) + setBackgroundColor(context.getColor(R.color.app_background)) + navigation.visibility = View.GONE + leading.visibility = View.GONE + addView(navigation, context.lp(48, 48)) + addView(leading, context.lp(WRAP, WRAP, start = 8, end = 4)) + addView(context.column { + gravity = Gravity.CENTER_VERTICAL + addView(titleView, context.lp()) + addView(subtitleView, context.lp()) + }, context.lp(width = 0, weight = 1f, start = 8, end = 4)) + addView(actions, context.lp(WRAP, WRAP)) + } + + fun setTitle(value: CharSequence?, subtitle: CharSequence? = null): TopBar = apply { + titleView.text = value + subtitleView.text = subtitle + subtitleView.visibility = if (subtitle.isNullOrBlank()) View.GONE else View.VISIBLE + } + + fun setNavigation(onBack: (() -> Unit)?): TopBar = apply { + navigation.visibility = if (onBack == null) View.GONE else View.VISIBLE + navigation.setOnClickListener { onBack?.invoke() } + } + + fun setNavigationIcon(@DrawableRes icon: Int, description: CharSequence): TopBar = apply { + navigation.setImageResource(icon) + navigation.contentDescription = description + } + + fun setLeading(view: View?): TopBar = apply { + leading.removeAllViews() + leading.visibility = if (view == null) View.GONE else View.VISIBLE + view?.let { leading.addView(it) } + } + + fun setActions(items: List, overflow: List = emptyList()): TopBar = apply { + actions.removeAllViews() + items.forEach { action -> + actions.addView(context.iconButton(action.icon, action.label, onClick = action.onClick).apply { + isEnabled = action.enabled + }, context.lp(48, 48)) + } + if (overflow.isNotEmpty()) { + actions.addView( + context.iconButton(R.drawable.ic_more_vert, context.getString(R.string.more_options)) { anchor -> + showMenu(anchor, overflow) + }, + context.lp(48, 48), + ) + } + } + + private fun showMenu(anchor: View, entries: List) { + PopupMenu(context, anchor, Gravity.END).apply { + entries.forEachIndexed { index, entry -> + menu.add(0, index, index, entry.label).isEnabled = entry.enabled + } + setOnMenuItemClickListener { item -> + entries.getOrNull(item.itemId)?.onClick?.invoke() + true + } + }.show() + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Views.kt b/app/src/main/java/dev/gf2log/app/ui/Views.kt new file mode 100644 index 0000000..64fad44 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -0,0 +1,470 @@ +package dev.gf2log.app.ui + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.text.InputFilter +import android.text.InputType +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import dev.gf2log.app.R + +const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT +const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT + +internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + +internal fun Context.dpf(value: Float): Float = value * resources.displayMetrics.density + +/** Linear layout parameters expressed in dp; MATCH and WRAP pass through unchanged. */ +internal fun Context.lp( + width: Int = MATCH, + height: Int = WRAP, + weight: Float = 0f, + top: Int = 0, + bottom: Int = 0, + start: Int = 0, + end: Int = 0, +): LinearLayout.LayoutParams = LinearLayout.LayoutParams( + if (width < 0) width else dp(width), + if (height < 0) height else dp(height), + weight, +).apply { + topMargin = dp(top) + bottomMargin = dp(bottom) + marginStart = dp(start) + marginEnd = dp(end) +} + +internal fun Context.column( + padding: Int = 0, + block: LinearLayout.() -> Unit = {}, +): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + if (padding > 0) setPadding(dp(padding), dp(padding), dp(padding), dp(padding)) + block() +} + +internal fun Context.row( + gravity: Int = Gravity.CENTER_VERTICAL, + block: LinearLayout.() -> Unit = {}, +): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + this.gravity = gravity + block() +} + +/** Typography roles from DESIGN.md; sizes follow the user's font scale. */ +internal enum class TextRole( + val sizeSp: Float, + val family: String, + val style: Int, + @ColorRes val color: Int, +) { + HEADLINE(22f, "sans-serif", Typeface.BOLD, R.color.text_primary), + TITLE(17f, "sans-serif-medium", Typeface.NORMAL, R.color.text_primary), + SUBTITLE(15f, "sans-serif-medium", Typeface.NORMAL, R.color.text_primary), + BODY(14f, "sans-serif", Typeface.NORMAL, R.color.text_primary), + SECONDARY(13f, "sans-serif", Typeface.NORMAL, R.color.text_secondary), + LABEL(12f, "sans-serif-medium", Typeface.NORMAL, R.color.text_secondary), + CAPTION(12f, "sans-serif", Typeface.NORMAL, R.color.text_secondary), +} + +internal fun Context.text( + value: CharSequence?, + role: TextRole = TextRole.BODY, + block: TextView.() -> Unit = {}, +): TextView = TextView(this).apply { + text = value + applyRole(role) + block() +} + +internal fun TextView.applyRole(role: TextRole) { + textSize = role.sizeSp + typeface = Typeface.create(role.family, role.style) + setTextColor(context.getColor(role.color)) + setLineSpacing(0f, if (role == TextRole.HEADLINE) 1.05f else 1.15f) +} + +/** Section label used above grouped rows. */ +internal fun Context.sectionHeader(value: CharSequence): TextView = text(value, TextRole.LABEL) { + isAllCaps = false + // Neutral ink: the accent stays reserved for things that can be tapped or are selected. + setTextColor(getColor(R.color.text_secondary)) + setPadding(dp(4), dp(20), dp(4), dp(6)) + isFocusable = false + accessibilityHeading() +} + +internal fun View.accessibilityHeading() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + isAccessibilityHeading = true + } +} + +internal fun Context.button( + label: CharSequence, + role: Controls.Role = Controls.Role.SECONDARY, + @DrawableRes icon: Int? = null, + onClick: (View) -> Unit, +): Button = Button(this).apply { + text = label + Controls.style(this, role) + icon?.let { drawable -> + val image = requireNotNull(getDrawable(drawable)).mutate() + image.setTint(currentTextColor) + val size = dp(18) + image.setBounds(0, 0, size, size) + setCompoundDrawablesRelative(image, null, null, null) + compoundDrawablePadding = dp(8) + } + setOnClickListener(onClick) +} + +internal fun Context.iconButton( + @DrawableRes icon: Int, + description: CharSequence, + @ColorRes tint: Int = R.color.text_primary, + onClick: (View) -> Unit, +): ImageButton = ImageButton(this).apply { + setImageResource(icon) + contentDescription = description + imageTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + intArrayOf(getColor(R.color.outline_strong), getColor(tint)), + ) + background = selectableBorderless() + scaleType = ImageView.ScaleType.CENTER + minimumWidth = dp(48) + minimumHeight = dp(48) + tooltipText = description + setOnClickListener(onClick) +} + +internal fun Context.icon( + @DrawableRes icon: Int, + @ColorRes tint: Int = R.color.text_secondary, + sizeDp: Int = 24, +): ImageView = ImageView(this).apply { + setImageResource(icon) + imageTintList = ColorStateList.valueOf(getColor(tint)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) +} + +internal fun Context.textField( + hintText: CharSequence, + @IdRes id: Int = View.NO_ID, + inputType: Int = InputType.TYPE_CLASS_TEXT, + singleLine: Boolean = true, + maxLength: Int? = null, +): EditText = EditText(this).apply { + if (id != View.NO_ID) this.id = id + hint = hintText + textSize = 15f + this.inputType = if (singleLine) inputType else inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE + if (singleLine) setSingleLine(true) else minLines = 2 + maxLength?.let { filters = arrayOf(InputFilter.LengthFilter(it)) } + elevation = 0f +} + +/** Runs [action] with the new text on every edit. */ +internal fun EditText.onTextChanged(action: (String) -> Unit) { + addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = action(s?.toString().orEmpty()) + override fun afterTextChanged(s: android.text.Editable?) = Unit + }) +} + +/** Search input with a leading magnifier and a trailing clear button shown while text is present. */ +internal fun Context.searchField(hintText: CharSequence, @IdRes id: Int, query: String, onQuery: (String) -> Unit): View { + val input = textField(hintText, id = id).apply { + setText(query) + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_search, 0, 0, 0) + compoundDrawablePadding = dp(8) + compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.text_secondary)) + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH + setPaddingRelative(paddingStart, paddingTop, dp(48), paddingBottom) + } + val clear = iconButton(R.drawable.ic_close, getString(R.string.clear_search)) { + input.setText("") + input.requestFocus() + } + clear.visibility = if (query.isEmpty()) View.GONE else View.VISIBLE + input.onTextChanged { text -> + clear.visibility = if (text.isEmpty()) View.GONE else View.VISIBLE + onQuery(text) + } + return FrameLayout(this).apply { + addView(input, FrameLayout.LayoutParams(MATCH, WRAP, Gravity.CENTER_VERTICAL)) + addView(clear, FrameLayout.LayoutParams(dp(48), dp(48), Gravity.END or Gravity.CENTER_VERTICAL)) + } +} + +/** Hidden inline error for dialogs and forms; [showMessage] reveals it and announces the change. */ +internal fun Context.errorLine(): TextView = text(null, TextRole.CAPTION) { + setTextColor(getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + visibility = View.GONE +} + +internal fun TextView.showMessage(message: CharSequence) { + text = message + visibility = View.VISIBLE +} + +/** Labeled input with an optional supporting or error line. */ +internal class LabeledField( + context: Context, + label: CharSequence, + val input: EditText, + private val supporting: CharSequence? = null, +) : LinearLayout(context) { + private val message = context.text(supporting, TextRole.CAPTION) { + setPadding(context.dp(4), context.dp(4), context.dp(4), 0) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + visibility = if (supporting.isNullOrBlank()) View.GONE else View.VISIBLE + } + + init { + orientation = VERTICAL + addView(context.text(label, TextRole.LABEL) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) + labelFor = input.id.takeIf { it != View.NO_ID } ?: View.generateViewId().also { input.id = it } + }, context.lp()) + addView(input, context.lp()) + addView(message, context.lp()) + } + + fun showError(error: CharSequence?) { + message.text = error ?: supporting + message.setTextColor( + context.getColor(if (error == null) R.color.text_secondary else R.color.destructive_action), + ) + message.visibility = if (message.text.isNullOrBlank()) View.GONE else View.VISIBLE + } +} + +internal fun Context.checkBox( + label: CharSequence, + checked: Boolean, + onChanged: (Boolean) -> Unit = {}, +): CheckBox = CheckBox(this).apply { + text = label + isChecked = checked + textSize = 14f + setTextColor(getColor(R.color.text_primary)) + buttonTintList = Controls.controlTint(this@checkBox) + minimumHeight = dp(48) + setOnCheckedChangeListener { _, value -> onChanged(value) } +} + +/** Full-row switch: the complete row toggles and exposes one accessible control. */ +internal fun Context.switchRow( + title: CharSequence, + detail: CharSequence?, + checked: Boolean, + enabled: Boolean = true, + badge: CharSequence? = null, + onChanged: (Boolean) -> Unit, +): View { + val toggle = Switch(this).apply { + isChecked = checked + isEnabled = enabled + thumbTintList = Controls.switchThumbTint(this@switchRow) + trackTintList = Controls.switchTrackTint(this@switchRow) + contentDescription = listOfNotNull(title, badge, detail).joinToString(". ") + setOnCheckedChangeListener { _, value -> onChanged(value) } + } + return row { + minimumHeight = dp(if (detail.isNullOrBlank()) 52 else 64) + setPadding(dp(4), dp(8), 0, dp(8)) + background = selectableRect() + isEnabled = enabled + if (enabled) setOnClickListener { toggle.toggle() } + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(row { + addView(text(title, TextRole.BODY) { + if (!enabled) setTextColor(getColor(R.color.text_secondary)) + }, lp(width = 0, weight = 1f)) + badge?.let { addView(badge(it), lp(width = WRAP, start = 8)) } + }, lp()) + detail?.takeIf(CharSequence::isNotBlank)?.let { + addView(text(it, TextRole.CAPTION) { setPadding(0, dp(2), 0, 0) }, lp()) + } + }, lp(width = 0, weight = 1f)) + // A permanently required option shows its badge instead of an inert switch. + if (enabled || badge == null) { + addView(toggle, lp(width = WRAP, start = 12)) + } else { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + isFocusable = true + contentDescription = toggle.contentDescription + } + } +} + +/** Compact non-interactive status or category label. */ +internal fun Context.badge( + value: CharSequence, + @ColorRes background: Int = R.color.surface_variant, + @ColorRes foreground: Int = R.color.text_secondary, +): TextView = text(value, TextRole.LABEL) { + setTextColor(getColor(foreground)) + textSize = 11f + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(dp(8), dp(3), dp(8), dp(3)) + this.background = GradientDrawable().apply { + setColor(getColor(background)) + cornerRadius = dpf(8f) + } +} + +internal fun Context.divider(insetStartDp: Int = 0): View = View(this).apply { + setBackgroundColor(getColor(R.color.outline)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + layoutParams = LinearLayout.LayoutParams(MATCH, maxOf(1, dp(1))).apply { + marginStart = dp(insetStartDp) + } +} + +/** Bounded tonal group for related rows; DESIGN.md "one tonal step" surface. */ +internal fun Context.panel( + emphasized: Boolean = false, + outlined: Boolean = false, + padding: Int = 16, + block: LinearLayout.() -> Unit = {}, +): LinearLayout = column { + setPadding(dp(padding), dp(padding), dp(padding), dp(padding)) + background = GradientDrawable().apply { + setColor(getColor(if (emphasized) R.color.accent_surface else R.color.surface)) + cornerRadius = dpf(16f) + if (outlined) setStroke(dp(1), getColor(R.color.outline)) + } + block() +} + +/** + * Grouped list container: rows are separated by inset hairlines and share one rounded surface, + * matching the Settings composition in PRODUCT.md. + */ +internal fun Context.group(rows: List): LinearLayout = column { + background = GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dpf(16f) + } + clipToOutline = true + setPadding(dp(8), dp(4), dp(8), dp(4)) + rows.forEachIndexed { index, view -> + if (index > 0) addView(divider(insetStartDp = 8)) + addView(view, lp()) + } +} + +/** Shows or hides a row added through [group] together with the divider above it. */ +internal fun View.setShownInGroup(shown: Boolean) { + val visibility = if (shown) View.VISIBLE else View.GONE + this.visibility = visibility + val parent = parent as? ViewGroup ?: return + val index = parent.indexOfChild(this) + if (index > 0) parent.getChildAt(index - 1).visibility = visibility +} + +/** A tappable grouped row with an optional icon, supporting text, and trailing value. */ +internal fun Context.navigationRow( + title: CharSequence, + detail: CharSequence? = null, + @DrawableRes icon: Int? = null, + trailing: CharSequence? = null, + destructive: Boolean = false, + enabled: Boolean = true, + showChevron: Boolean = true, + onClick: () -> Unit, +): View = AccessibleRow(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(if (detail.isNullOrBlank()) 52 else 64) + setPadding(dp(8), dp(8), dp(4), dp(8)) + background = selectableRect() + isEnabled = enabled + contentDescription = listOfNotNull(title, trailing, detail?.takeIf(CharSequence::isNotBlank)) + .joinToString(". ") + setOnClickListener { onClick() } + val primaryColor = when { + !enabled -> R.color.text_secondary + destructive -> R.color.destructive_action + else -> R.color.text_primary + } + icon?.let { + addView(icon(it, if (destructive) R.color.destructive_action else R.color.text_secondary), lp( + width = 24, + height = 24, + end = 16, + )) + } + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(text(title, TextRole.BODY) { + setTextColor(getColor(primaryColor)) + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, lp()) + detail?.takeIf(CharSequence::isNotBlank)?.let { + addView(text(it, TextRole.CAPTION) { setPadding(0, dp(2), 0, 0) }, lp()) + } + }, lp(width = 0, weight = 1f)) + trailing?.let { + addView(text(it, TextRole.SECONDARY) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + maxWidth = dp(160) + }, lp(width = WRAP, start = 8)) + } + if (showChevron) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) +} + +/** Exposes a composite row to accessibility services as one button. */ +internal class AccessibleRow(context: Context) : LinearLayout(context) { + override fun getAccessibilityClassName(): CharSequence = Button::class.java.name +} + +internal fun Context.selectableRect(): android.graphics.drawable.Drawable = + android.graphics.drawable.RippleDrawable( + ColorStateList.valueOf(getColor(R.color.control_ripple)), + null, + GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dpf(12f) + }, + ) + +internal fun Context.selectableBorderless(): android.graphics.drawable.Drawable = + android.graphics.drawable.RippleDrawable( + ColorStateList.valueOf(getColor(R.color.control_ripple)), + null, + null, + ) + +internal fun Context.frame(block: FrameLayout.() -> Unit = {}): FrameLayout = + FrameLayout(this).apply(block) diff --git a/app/src/main/res/drawable/bg_button.xml b/app/src/main/res/drawable/bg_button.xml deleted file mode 100644 index d6c6066..0000000 --- a/app/src/main/res/drawable/bg_button.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_edit_text.xml b/app/src/main/res/drawable/bg_edit_text.xml index 8d3b534..f7a9011 100644 --- a/app/src/main/res/drawable/bg_edit_text.xml +++ b/app/src/main/res/drawable/bg_edit_text.xml @@ -3,15 +3,14 @@ - - + + - - + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..5d03bbe --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle.xml b/app/src/main/res/drawable/ic_add_circle.xml deleted file mode 100644 index 9c4919a..0000000 --- a/app/src/main/res/drawable/ic_add_circle.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..1dc8252 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 0000000..5665383 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bolt.xml b/app/src/main/res/drawable/ic_bolt.xml new file mode 100644 index 0000000..2c13ff0 --- /dev/null +++ b/app/src/main/res/drawable/ic_bolt.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug.xml b/app/src/main/res/drawable/ic_bug.xml new file mode 100644 index 0000000..be4495c --- /dev/null +++ b/app/src/main/res/drawable/ic_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..9068917 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..63d572f --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 0000000..6e12ab6 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_circle_outline.xml b/app/src/main/res/drawable/ic_circle_outline.xml new file mode 100644 index 0000000..13a15fc --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..c156145 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code.xml b/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..305b5d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_compare.xml b/app/src/main/res/drawable/ic_compare.xml new file mode 100644 index 0000000..fa3beb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_compare.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..da6770d --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..d4a2507 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..87241b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline.xml b/app/src/main/res/drawable/ic_info_outline.xml index c61ca2c..517eac4 100644 --- a/app/src/main/res/drawable/ic_info_outline.xml +++ b/app/src/main/res/drawable/ic_info_outline.xml @@ -7,7 +7,7 @@ diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..9337852 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..51dc2ea --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_schedule.xml b/app/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 0000000..6760b01 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..3a66cde --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shield.xml b/app/src/main/res/drawable/ic_shield.xml new file mode 100644 index 0000000..74ebb66 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000..1d79db4 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..4501239 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_storage.xml b/app/src/main/res/drawable/ic_storage.xml new file mode 100644 index 0000000..ef64421 --- /dev/null +++ b/app/src/main/res/drawable/ic_storage.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_today.xml b/app/src/main/res/drawable/ic_today.xml new file mode 100644 index 0000000..c0c785f --- /dev/null +++ b/app/src/main/res/drawable/ic_today.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 0000000..1522898 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml new file mode 100644 index 0000000..7ec5489 --- /dev/null +++ b/app/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..aa1664b --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 38473ec..86ba3da 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,43 +1,28 @@ GF2logger - 캡처 워크스페이스 데이터 도구 캡처 상태 준비됨 캡처 중 - 캡처가 중지되었습니다 - 지원 클라이언트 ID - 패키지 ID - 패키지 ID - com.haoplay.game.and.exilium\nHaoPlay 클라이언트\n\ncom.Sunborn.SnqxExilium.Glo\nDarkwinter 클라이언트 홈 서클 주간 - 전체 보기 - 설치된 HaoPlay 및 Darkwinter 클라이언트의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다. - 캡처 준비 + 계속 캡처 한 번만 캡처 - %1$s 서클 식별 정보\n%2$s 서클 명단\n%3$s 활동 근거\n%4$s 가입/탈퇴 변경 중지 설정 열기 서클 관리 백업 내보내기 - 서클 관리 백업 가져오기 - 백업에 포함된 서클의 관리 데이터를 대체합니다. 다른 서클의 데이터는 변경하지 않습니다. 캡처를 중지한 상태로 유지하세요. - 백업을 내보내거나 가져오기 전에 캡처를 중지하세요. + 캡처 중에는 패킷이 중간에 섞이지 않도록 백업과 복원을 할 수 없습니다. 서클 관리 백업을 내보냈습니다 - 서클 관리 백업을 복원했습니다 - 서클 관리 백업을 내보내거나 복원할 수 없습니다 - 올바른 서클 관리 백업 파일이 아닙니다. + 서클 관리 백업을 내보낼 수 없습니다 언어 English - 한국어 한국어 화면 테마 시스템 설정 라이트 다크 - 기기 설정을 따르거나 GF2logger를 라이트 또는 다크 모드로 고정합니다. 건너뛰기 이전 다음 @@ -47,36 +32,34 @@ 환영합니다 메인 화면 소녀전선2: 망명 클라이언트의 통신 패킷을 캡처하고, 패킷 수집 현황을 한눈에 확인합니다. - 캡처 준비는 사용자가 중지할 때까지 계속 실행됩니다. + 계속 캡처는 사용자가 중지할 때까지 실행됩니다. 한 번만 캡처는 서클 식별 정보, 멤버, 활동, 동향을 확인한 뒤 자동으로 중지합니다. - 최근 패킷 및 저장한 패킷 기록은 로컬에 보관됩니다. + 최근·보관 패킷은 이 휴대전화의 최근/보관 탭에 보관됩니다. VPN 동의 화면이 표시됩니다. GF2logger는 소녀전선2: 망명 앱에 대해서만 통신 패킷을 관찰하며, TLS를 해독하거나 원본 네트워크 패킷을 저장하지 않습니다. 설정 화면에 표시되는 정보를 조정하고, 기기에 저장하거나 외부로 보내는 정보를 관리합니다. 언어와 테마를 선택합니다. - 전체 백업을 만들거나 복원합니다. + 서클 데이터를 앱 설정과 함께 또는 따로 백업하고 복원합니다. Discord 웹훅을 설정하고 필요한 패킷 종류를 선택합니다. 서클 관리 캡처하거나 불러온 멤버 데이터가 선택한 서클의 멤버 정보 및 주간 표에 반영됩니다. 현재 가입 중이거나 과거에 탈퇴한 멤버의 정보와 가입·탈퇴 이력을 검색하고 관리합니다. CSV를 적용하기 전에 변경 내용을 미리 보고, 자동 체크포인트로 최근 가져오기를 되돌릴 수 있습니다. - 분리된 서클을 전환하고, 스냅샷을 비교하거나 선택한 멤버만 내보낼 수 있습니다. - 주간 테이블 + 분리된 서클을 전환하고, 스냅샷을 비교하거나 멤버를 길게 눌러 선택해 내보낼 수 있습니다. + 주간 과업표 일반 주간과 흙먼지 주간이 구분된 주간 공적표를 확인합니다. 각 셀을 눌러 해당 값의 근거와 사용된 패킷 정보를 확인할 수 있습니다. - 수동으로 공적치나 흙먼지 점수 등을 수정할 수 있으며, 공유 버튼을 통해 주간 테이블을 저장하거나 공유할 수 있습니다. + 연필 버튼으로 공적이나 흙먼지 값을 수정하고, 공유 버튼으로 주간 표를 저장하거나 공유합니다. 커트라인 설정은 선택한 기준보다 낮거나 낮을 가능성이 있는 셀을 강조합니다. 파싱된 패킷 화면 기록을 열어 표 또는 디코딩된 CSV 전체 내용을 확인하거나 내보냅니다. - 원본 CSV 보기는 디코딩된 전체 내용을 표시하고, 복사는 클립보드에 저장합니다. - 원본 CSV 전송은 설정된 Discord 웹훅으로 전송됩니다. + 표와 디코딩된 원본 텍스트를 오가며 볼 수 있고, 복사하면 클립보드에 저장됩니다. + 메뉴에서 원본 CSV를 Discord 웹훅으로 보낼 수 있습니다. 패킷에 담겨 있는 여러 정보를 통해 멤버 및 주간 테이블을 완성해 나갈 수 있습니다. 백업 - 백업 불러오기 - 전체 백업에 포함된 서클의 모든 관리 데이터와 앱 설정을 복원합니다. 다른 서클은 변경하지 않습니다. + 백업 복원 전체 정보 백업하기 앱 설정과 선택한 서클의 가입 이력 및 주간표를 포함한 모든 관리 데이터를 저장합니다. - 앱 설정과 백업에 포함된 서클 데이터를 대체한 뒤 해당 서클을 선택합니다. 다른 서클은 변경하지 않습니다. 복원하는 동안 캡처를 중지하세요. 올바른 전체 백업 파일이 아닙니다. 전체 정보 백업을 저장했습니다 전체 정보 백업을 복원했습니다 @@ -87,18 +70,15 @@ 주간 과업표 최근 스냅샷 비교 선택 멤버 내보내기 - %1$s 선택 먼저 멤버를 하나 이상 선택하세요 선택 멤버를 내보냈습니다 이름 또는 UID 검색 - 가입 + 탈퇴 - 가입 - 정렬: 이름 - 정렬: 이번 주 공적 - 정렬: 누적 공적 - 정렬: 최근 접속 + 가입 중 + 이름 + 이번 주 공적 + 누적 공적 + 최근 접속 아직 서클 멤버 스냅샷을 캡처하지 않았습니다. - 가입 %1$d명 · 탈퇴 %2$d명\n최근 스냅샷: %3$s (%4$s) 일치하는 멤버가 없습니다 이번 주 공적 누적 공적 @@ -107,15 +87,11 @@ %1$s → %2$s 가입 탈퇴 - 멤버 변화 흙먼지 점수 없음 멤버 상세 가입 멤버 탈퇴 멤버 - #%1$d · %2$s - 멤버 이름 - 비고 닉네임 개인 메모 저장 @@ -125,26 +101,19 @@ 가입 이력 추가 가입 이력 추가 멤버 삭제 - 멤버를 삭제하면 저장된 가입 이력, 비고와 주간 과업표 수정사항이 영구적으로 삭제됩니다. 이후 새 명단 패킷에서 멤버가 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다. + 멤버를 삭제하면 가입 이력, 개인 메모, 주간 과업표 수정 내용이 영구적으로 삭제됩니다. 이후 명단에서 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다. 멤버를 삭제했습니다 멤버를 삭제할 수 없습니다 - 탈퇴한 멤버 수동 추가 - 가입: - 탈퇴: + 탈퇴한 멤버 추가 메모 - 미설정 날짜 선택 시간 (선택) - 먼저 날짜를 선택하세요 날짜 지우기 시간 지우기 UID 추가 - 지우기 중복되지 않는 양의 UID와 닉네임을 입력하고 가입·탈퇴 날짜를 선택한 뒤 탈퇴를 가입 이후로 설정하세요. - 가입 이력 %1$d\n가입: %2$s\n탈퇴: %3$s 가입 이력 %1$d - %1$s → %2$s 가입 이력 삭제 가입/탈퇴 이력을 삭제합니다. 한 번 삭제하면 되돌릴 수 없습니다. 계속하시겠습니까? 가입 이력을 삭제했습니다 @@ -152,57 +121,39 @@ 유일한 가입 이력은 삭제할 수 없습니다. 이 기록을 없애려면 멤버 삭제를 이용해 주세요. 알 수 없음 가입·탈퇴 시각 편집 - 가입 (yyyy-MM-dd HH:mm), 모르면 비워 두기 - 탈퇴 (yyyy-MM-dd HH:mm), 가입/모르면 비워 두기 가입 날짜를 선택하고 탈퇴를 가입 이후로 설정하세요. 시간은 선택 사항입니다 흙먼지 주간 일반 주간 - %1$s – %2$s · 05:00 초기화 (%3$s) 이전 주 - 이번 주 다음 주 주간 과업표를 불러오는 중… 주간 과업표를 불러올 수 없습니다. 다시 시도 - 이 주간에는 아직 비교 가능한 데이터가 없습니다. + 서로 다른 게임 일자에 서클 멤버 화면을 캡처하거나 명단 CSV를 가져오면 이 주의 표가 만들어집니다. 멤버 - 순위 합계 - 공적 %1$d\n출석 %2$s · 과업 %3$s - 공적 %1$d\n%2$d점 · %3$s회\n출석 %4$s · 과업 %5$s - 공적 %1$d - 공적 %1$d\n%2$d점 · %3$s회 비고 가입/탈퇴 가입 탈퇴 - 불명 + 날짜 알 수 없음 선택한 게임 날짜의 비고 추가 비고 추가 이번 주에는 최대 128개의 비고가 이미 저장되어 있습니다. 비고 삭제 - 이 비고를 삭제할까요? 삭제 비고를 삭제했습니다 주간 CSV 내보내기 주간 CSV를 내보냈습니다 + 주간 CSV를 내보낼 수 없습니다 전체 주간 과업표 내보내기 내보낼 주간 과업표가 없습니다. - 가입 - 재가입 - 탈퇴 설정 앞으로 기록할 디코딩 페이로드를 선택합니다. 기존 기록에는 영향을 주지 않습니다. - 패킷과 보고서의 표기 언어에도 함께 적용됩니다. - 기기 설정을 따르거나 라이트·다크 모드로 고정합니다. - 게임 시간대 - 하루 초기화 시간 - 게임 서버 지역을 선택하면 서버의 05:00 초기화 시각을 이 기기의 시간대로 환산합니다. 서버 지역 자동 선택 선택되지 않음 — 첫 캡처 전에 선택하세요 자동 · %1$s - 서클 프로필이 감지된 뒤 자동 선택을 사용할 수 있습니다. 시간대 수동 설정 Darkwinter 글로벌 Darkwinter 중국 @@ -210,7 +161,7 @@ HaoPlay 일본 HaoPlay 한국 HaoPlay 아시아 - 새로운 서클 감지 + 새 서클 감지 게임 클라이언트는 확인했지만 패킷만으로 서버를 확정할 수 없습니다. 패킷을 저장하기 전에 서버를 선택하세요. 이 서클의 서버 선택 확인 전까지 파싱된 패킷은 메모리에만 보관됩니다. GF2logger를 강제 종료하면 삭제됩니다. @@ -219,14 +170,11 @@ 계속 선택 수집한 패킷을 버리시겠습니까? 서버 선택을 취소하면 수집한 패킷이 저장되지 않을 수 있습니다. 확인되지 않은 서클의 패킷을 버리시겠습니까? - 서클 관리 + 서클 프로필 관리 감지된 서클의 서버를 수정하거나 격리된 데이터를 완전히 삭제합니다. - %1$s · %2$s · ID %3$d 선택됨 - 클라이언트/서버 수정 %1$s 서버 수정 - 확인된 클라이언트: %1$s (수정 불가)\n%2$s 서클의 서버를 선택하세요. - 서클을 수정하거나 삭제하기 전에 캡처를 중지하세요. + 서클 프로필은 캡처가 멈춘 상태에서만 바꿀 수 있습니다. 서클 서버를 수정할 수 없습니다. 데이터는 이동되지 않았습니다. 서클 삭제 %1$s 삭제 @@ -236,13 +184,10 @@ 서클 삭제를 완료할 수 없습니다. 안전하게 다시 정리합니다. %1$s · 이 기기의 다음 초기화 %2$s (%3$s) 수동 · %1$s (기기: %2$s) - 게임: %1$s · 기기: %2$s 게임 시간대를 변경하고 주간 표 히스토리를 다시 구성했습니다 시간대 변경과 주간 표 히스토리 재구성을 완료하지 못했습니다 사용자가 직접 선택한 백업 파일로만 전체 앱 데이터를 이동합니다. 뒤로 - %1$s (payloadType = %2$d) - 필수 항목으로 항상 활성화됩니다. %1$s %1$s, 페이로드 유형 %2$s 알 수 없음 서클 멤버 @@ -268,28 +213,17 @@ 보유 무기 파츠의 레벨, 세트 플래그와 잠금 상태입니다. 보유 공용 스킬 아이템입니다. id는 플레이어 UID가 아니라 창고 항목 ID입니다. 저장한 편성과 장착물 정보입니다. - 최근 파싱한 패킷 (최대 %1$d개) - 선택 기록 삭제 - 선택 기록 저장 - 보관 패킷 (최대 %1$d개) - 선택 보관 기록 삭제 - 최근 서클 CSV 내보내기 - 서클 CSV 파일 가져오기 - CSV 가져오기 완료: 보관 %1$d, 현재 %2$d, 과거 %3$d, 건너뜀 %4$d + 최신 명단 CSV 내보내기 + 명단 CSV 가져오기 선택한 서클 CSV 파일을 가져오지 못했습니다 아직 캡처한 패킷이 없습니다 아직 보관한 패킷이 없습니다 - %1$s 선택 - 원본 CSV 보기 파싱 패킷 복사 GF2logger 파싱 패킷 파싱 패킷을 복사했습니다 캡처 준비 중 - %1$s 내보냄 서클 CSV를 내보낼 수 없습니다 캡처한 서클 CSV가 없습니다 - 먼저 기록을 하나 이상 선택하세요 - 먼저 보관 기록을 하나 이상 선택하세요 선택 기록을 저장할 수 없습니다 선택한 기록은 이미 보관되어 있습니다 보관 한도에 도달했습니다 (%1$d) @@ -301,7 +235,7 @@ 완료된 캡처 세션이 없습니다. %1$s\n전달: %2$d KiB · 검사: %3$d KiB\n디코딩: %4$d · 경고: %5$d · 누락 청크: %6$d\n알 수 없는 페이로드 (유형:횟수): %7$s 주간 과업표 설정 - 확인할 기준만 활성화하세요. 활성화한 기준보다 낮은 값은 노란색으로 표시됩니다. + 확인할 기준만 켜세요. 켜 둔 기준보다 낮은 값은 주황색 밑줄로 표시됩니다. 일일 커트라인 주간 커트라인 공적 @@ -311,9 +245,8 @@ 주간 과업 참여 일수 최소값 설정 저장 - 0 이상의 올바른 기준값을 입력하세요. 멤버 순서 - 목록을 정렬하거나 멤버 옆 손잡이를 길게 눌러 끌어 놓으세요. 이후 주간 과업표에도 같은 순서가 유지됩니다. + 주간 표는 이 순서로 멤버를 표시합니다. 손잡이를 끌어 조정하면 자동으로 저장됩니다. 정렬 기준 정렬 방향 가입일 @@ -321,9 +254,9 @@ 누적 공적 오름차순 내림차순 - 정렬 적용 + 목록에 적용 멤버 순서 초기화 - %1$s 이동하려면 길게 누르기 + %1$s 끌어서 이동 멤버 순서 편집 CSV 복사 GF2logger 스냅샷 비교 CSV @@ -343,10 +276,6 @@ 확인 취소 주간 과업표 수정사항을 저장했습니다. - 0 이상의 숫자를 입력하세요. 흙먼지 참여횟수는 0에서 3 사이여야 합니다. - ≥ 표시는 아직 끝나지 않은 날짜에서 캡처 시점까지 확인된 최솟값입니다.\n? 표시는 현재 캡처만으로 해당 날짜를 확정할 수 없음을 뜻합니다.\n완료된 날짜에 가능한 0/50/90점 배치가 여러 개라면 임의의 추정값을 표시하지 않고 알 수 없음으로 유지합니다.\n건너뛴 기간이 지나치게 길 경우 이전 내역이 전부 ?로 표시될 수 있습니다. - ≥ 표시는 확인된 최솟값이며, ? 표시는 현재 캡처만으로 해당 날짜를 안전하게 나눌 수 없음을 뜻합니다.\n누락된 흙먼지 날짜의 공적, 점수, 참여횟수, 출석, 일일 과업은 안전하게 나눌 수 없으므로 카운터로 보강된 합계를 확인하고 아는 사실만 직접 수정하세요.\n건너뛴 기간이 지나치게 길 경우 이전 내역이 전부 ?로 표시될 수 있습니다. - %1$s 확인 기록 %1$d개를 삭제했습니다 @@ -359,18 +288,13 @@ 보관 기록 %1$d개를 삭제했습니다 - 근거 상태 근거 상태 읽는 법 - ✓ 확정 · ≥ 확인된 최솟값 · ? 알 수 없음 — 자세히 보기 마감 경계, 직접 이벤트, 수동 수정 또는 가능한 모든 배치의 일치로 정확한 값이 확정되었습니다. 현재 캡처로 최소 이 값은 확인되지만, 누락된 근거에 따라 최종 값은 더 높을 수 있습니다. 현재 캡처만으로 안전하게 확정할 수 없어 GF2logger가 임의로 추측하지 않습니다. 완료된 날짜에는 유효한 일일 공적 배치만 사용합니다. 가능한 0/50/90 조합이 여러 개면 알 수 없음으로 유지합니다. 누락된 흙먼지 날짜를 안전하게 나눌 수 없어도 카운터로 확인된 주간 합계는 계속 활용할 수 있습니다. 캡처 공백이 길면 이전 항목이 알 수 없음으로 남을 수 있습니다. 각 셀을 누르면 사용된 근거를 확인할 수 있습니다. - 관측 일수 %1$d/%2$d · 확정 %3$d · 최솟값 %4$d · 알 수 없음 %5$d\n직접 로그인 %6$d일 · 직접 과업 %7$d일 · 마감 경계 %8$d개 - %1$s · %2$s · %3$s - 판정: %1$s\n\n근거:\n%2$s 확정 확인된 최솟값이며 최종 값은 더 높을 수 있음 현재 캡처만으로 알 수 없음 @@ -390,20 +314,18 @@ 긴 공백 때문에 일일 배치를 하나로 확정할 수 없습니다. 가능한 배치가 둘 이상이므로 애플리케이션은 임의로 추측하지 않습니다. CSV 가져오기 검토 - 적용 대상: %1$s (ID %2$d)\n%3$s · %4$s\n\n21917만으로는 적용 대상을 확인할 수 없습니다. 선택한 서클이 맞는지 확인하세요. - 검증된 파일: %1$d\n이미 존재함: %2$d\n과거 캡처: %3$d\n포함된 멤버: %4$d\n새 UID: %5$d\n이름 차이: %6$d\n예상 가입: %7$d\n예상 탈퇴: %8$d\n선택 용량: %9$d KiB\n캡처 범위: %10$s\n\n아직 데이터는 변경되지 않았습니다. 가져오기 전에 자동 체크포인트를 생성합니다. 선택한 서클이 변경되었거나 더 이상 존재하지 않습니다. CSV 데이터는 가져오지 않았습니다. 적용할 서클을 다시 선택한 뒤 시도하세요. %1$s — %2$s CSV 파일을 검증하고 미리보기를 준비하는 중… 체크포인트를 만들고 CSV 파일을 가져오는 중… - CSV 가져오기 완료: 보관 %1$d, 현재 %2$d, 과거 %3$d, 건너뜀 %4$d. 실행 취소할 수 있습니다. + 가져오기 완료: 파일 %1$d개 보관, 현재 %2$d, 과거 스냅샷 %3$d, 건너뜀 %4$d. 홈에서 되돌릴 수 있습니다. 마지막 CSV 가져오기 실행 취소 마지막 CSV 가져오기 직전에 만든 자동 체크포인트를 복원할까요? 가져오기 이후 서클 데이터가 변경되었다면 되돌리기를 거부합니다. 사용 가능한 CSV 가져오기 체크포인트가 없습니다. CSV 가져오기 체크포인트를 복원하는 중… 마지막 CSV 가져오기를 실행 취소했습니다. 체크포인트가 없거나 올바르지 않거나, 가져오기 이후 서클 데이터가 변경되어 되돌릴 수 없습니다. - CSV 미리보기, 가져오기 또는 실행 취소 전에 캡처를 중지하세요. + 명단이 겹쳐 기록되지 않도록 CSV 가져오기와 되돌리기는 캡처가 멈춘 뒤에 할 수 있습니다. 주간 테이블 공유 멤버 이름 포함 UID 포함 @@ -427,15 +349,14 @@ 저장된 Discord 웹훅을 제거할 수 없습니다. 원본 CSV 전송 이 원본 CSV 파일을 설정된 Discord 채널로 전송할까요? 캡처된 서클 데이터가 앱 외부로 이동합니다. - 전송하기 전에 설정에서 Discord 수신 웹훅을 구성하세요. + 전송하기 전에 설정에서 Discord 수신 웹훅을 설정하세요. 원본 CSV를 Discord로 전송하는 중… 원본 CSV를 Discord로 전송했습니다. 원본 CSV를 전송할 수 없습니다. 웹훅과 네트워크 연결을 확인하세요. 이 파싱 패킷에는 전송할 수 있는 유효한 CSV 본문이 없습니다. - 표 히스토리 + 표 기록 자동으로 기록된 현재 주간 테이블의 과거 히스토리 목록입니다. 아직 기록된 주간 테이블 변경 이력이 없습니다. - 멤버 %1$d명 현재 복원된 항목 표 히스토리 미리보기 이 표 복원 @@ -446,18 +367,235 @@ 서클 선택 선택 새 서클 프로필 추가 - 신규 서클 정보 입력 + 새 서클 정보 새롭게 등록할 서클의 정보를 입력해 주세요. 클라이언트 서클 이름 서클 ID 모든 항목을 확인하세요. 같은 클라이언트, 서버 및 서클 ID가 이미 등록되어 있을 수 있습니다. - 프로필 잊기 - 프로필 수용량을 확보하기 위해 이 서클을 선택 목록에서 제거할까요? 비공개 데이터는 유지되며, 해당 서클을 다시 감지하거나 복원하면 돌아옵니다. - 잊기 - 이 서클 프로필을 제거할 수 없습니다 감지된 서클 없음 아직 수집된 서클 정보가 없습니다. HaoPlay 캡처 서버 Darkwinter 캡처 서버 + 더보기 + %1$s · %2$s · ID %3$d + 프로필 + 완료 + 계속 + %1$s의 서버 + 선택 취소 + 가입 %1$d · 탈퇴 %2$d · 변화 %3$d + 비교한 스냅샷 + 현재 + 날짜: %1$s + 시간: %1$s + 이 UID의 멤버가 이미 있습니다. + 게임 동향의 정확한 시각 · 수정 불가 + 전체 + 탈퇴 + 탈퇴는 가입 이후여야 합니다. + 가입일과 탈퇴일을 모두 선택하세요. + 닉네임을 입력하세요. + 양수 UID를 입력하세요. + 최근 접속 + 접속 %1$s + 레벨 + Lv. %1$d + 스냅샷을 비교하는 중… + 멤버를 불러오는 중… + %1$s: 이번 주 공적 +%2$d, 누적 공적 +%3$d, 흙먼지 점수 +%4$d + 수치 변화 · %1$d + 게임 닉네임 대신 모든 화면에 표시됩니다. + %2$d명 중 %1$d번째, %3$s + 멤버 순서를 초기화했습니다 + 이 기기에만 보관됩니다. 직접 포함하지 않으면 공유 이미지에 나타나지 않습니다. + 이 멤버에 대한 개인 메모 + 프로필 + %1$s → %2$s\n가입: %3$s · 탈퇴: %4$s + 다른 이력과 겹치거나 열린 이력이 둘이 됩니다. + 저장할 수 없습니다. 이력은 겹칠 수 없으며 열린 이력은 하나만 가능합니다. + 이번 주 + 아래로 이동 + 위로 이동 + 가입 중인 멤버가 없습니다 + 가입 이력이 없습니다 + 게임의 서클 › 멤버 화면에서 캡처하거나 홈에서 명단 CSV를 가져오세요. + 서클 데이터를 불러오지 못했습니다. + 현재 + 초기화 + 기본 가입일 순서로 되돌릴까요? 직접 정한 순서는 삭제됩니다. + 모두 선택 + 멤버 선택 + 선택됨 + 정렬 기준 + 목록 전체 정렬 + 이전 버전 + 수동 + 명단 관측 + 정확 (동향) + 가입 중 + 최근 스냅샷 + 탈퇴 + 숫자 플레이어 UID + + %1$d명 + + + %1$d명 선택 + + 적용 + 최소 %1$s + 0–3 · 비우면 해제 + 커트라인 미달 + 비우면 해제 + 수정 취소 + 자동 + 수동 값 지우기 + 0 이상의 정수를 입력하세요. + 0–%1$d + 0부터 %1$d까지의 정수를 입력하세요. + 비고 삭제: %1$s + 저장하지 않은 표 수정 내용이 사라집니다. + 수정 내용을 버릴까요? + 표 수정 중 + 수정됨 + 모든 값 확정 + 출석과 일일 과업: ✓ 함, × 안 함 확정, - 관측 없음, ? 판단 불가. + 관측 %1$d/%2$d일\n확정 %3$d · 최솟값 %4$d · 미확인 %5$d + 일부 값은 최솟값이거나 미확인 + 알 수 없음 + 이번 주로 + 안내 + 참여 횟수는 0부터 3까지입니다. + 0 이상의 정수를 입력하거나 비워 두세요. + %1$s의 %2$s 값을 확인하세요. + 출 + 게임에서 확인한 값만 바꾸세요. 손대지 않은 항목은 캡처 근거를 유지합니다. + 아니요 + 이번 주에는 가입·탈퇴가 없습니다. + 이번 주 비고가 없습니다. + 이 주의 표가 아직 없습니다 + 관측 없음 + 비고를 추가했습니다 + 게임 일자 + 비고를 입력하세요. + 과 + 근거: 관측 %1$d/%2$d일\n확정 %3$d · 최솟값 %4$d · 미확인 %5$d + %1$d위 + 주 선택 + 민감한 정보입니다. 기본값은 꺼짐입니다. + 현재 표 보기 + 수정 시작 + 합계는 일별 칸에서 계산됩니다. + 나가기 전에 변경 내용을 저장할까요? + 저장하지 않은 변경 + %1$s – %2$s + 초기화 05:00 · %1$s + %1$s, %2$s + %1$s: %2$s + #%1$d + %1$s (#%2$d) + %1$s · %2$s + 날짜 칸을 눌러 값을 바꾸세요. 수정한 칸은 저장할 때까지 강조됩니다. + 기록된 이전 버전의 표입니다. 복원하면 새 근거로 표가 바뀔 때까지 이 버전을 표시합니다. + 이 기록은 더 이상 사용할 수 없습니다. + 표 히스토리 (%1$d) + 예 + + %1$d칸 수정 + + 글로벌 + 중국 + 일본 + 한국 + 아시아 + 선택한 서클 + 버전 + 백업 및 복원 + 설정에서 서클 데이터를 내보내거나 복원 + 백업을 저장하는 중… + 백업을 복원하는 중… + Android가 VPN을 시작하기를 기다리는 중입니다. + 캡처 중에 게임의 서클 › 멤버와 동향 화면을 여세요. + 수집됨 + 변경 + 서클 활동 + 서클 정보 + 멤버 명단 + 동향 + 파일 선택 + 설치됨 + 미설치 + 복사했습니다 + 복사 + 명단 CSV에는 서클 정보가 없습니다. 파일을 넣을 서클을 직접 선택하세요. + 가져올 서클 + 어느 서클로 가져올까요? + 명단 파일에는 서클 정보가 없습니다. 올바른 서클인지 확인하세요. + 아직 아무것도 바뀌지 않았습니다. 가져오기 전에 자동 체크포인트를 만들어 되돌릴 수 있습니다. + 이미 있음 + 유효한 파일 + 현재보다 과거 데이터 + 가입 추정 + 파일 속 멤버 + 이름 차이 + 새 UID + 캡처 기간 + 전체 크기 + 탈퇴 추정 + 패킷을 삭제할까요? + 원본 CSV를 내 Discord 채널로 전송 + 최신 명단을 스프레드시트용으로 저장 + 선택한 서클의 멤버, 이력, 주간 표 + 가져오기 + 먼저 미리 보고, 자동 체크포인트로 되돌릴 수 있음 + %1$d KiB + 지난 캡처의 트래픽·파서 통계 + 패킷을 여는 중… + 서클이 감지되면 패킷 기록이 표시됩니다. + 패킷 기록 + 이 패킷은 더 이상 없습니다. + 원본 + 표 + 숫자 서클 ID + 초기화 시각을 바꾸고 주간 기록을 다시 만드는 중… + 최근 %1$d/%2$d + 필수 + 백업에 들어 있는 서클만 교체 + 복원하면 백업에 들어 있는 서클의 데이터를 교체하고 그 서클을 선택합니다. 전체 백업이면 앱 설정도 교체됩니다. 다른 서클은 바뀌지 않습니다. + 명단 CSV를 내보냈습니다 + 보관 + 보관 %1$d/%2$d + 보내기 + 설정 + 정보 + 캡처 + 각 클라이언트에서 새 서클을 처음 감지할 때 이 서버를 사용합니다. + 화면 + 서클 + 모두 보기 (%1$d) + 시작 안내 다시 보기 + 되돌리기 + 마지막 가져오기 직전 체크포인트로 복원 + 대기 중 + + 패킷 %1$d개를 삭제할까요? 되돌릴 수 없습니다. + + %1$s 처음 확인 + 서클 백업을 복원했습니다 + GF2logger — %1$s + 멤버 %1$d + %1$s 통신을 보는 중입니다. 게임에서 서클 화면을 여세요. + %1$s, %2$s + 오늘, %1$s %2$s + 최근 명단 · %1$s + 가입 %1$d + 탈퇴 %1$d + 서클을 저장하는 중… + 검색어 지우기 + 직접 옮긴 순서를 포함해 현재 순서를 모두 바꿉니다. + 적용 후 다음 날 + 캡처 중입니다 + 캡처 중지 후 계속 + 출석이 아니요이면 과업을 예로 둘 수 없습니다. diff --git a/app/src/main/res/values-ko/strings_capture.xml b/app/src/main/res/values-ko/strings_capture.xml new file mode 100644 index 0000000..ce1c59c --- /dev/null +++ b/app/src/main/res/values-ko/strings_capture.xml @@ -0,0 +1,45 @@ + + + 캡처 준비 중 + 캡처가 이미 실행 중입니다 + %1$s만 캡처 중 + 이 빌드에서는 네이티브 전달 코어를 사용할 수 없습니다 + 이전 서클 기록을 가져올 수 없습니다 + 지원되는 HaoPlay 또는 Darkwinter 클라이언트를 먼저 설치하세요 + VPN을 설정할 수 없습니다: %1$s + VPN 권한이 취소되었습니다 + 네이티브 전달 코어를 시작하지 못했습니다 + 캡처가 예기치 않게 중지되었습니다. 다시 시작해 주세요. + 서클 명단, 활동, 동향을 모두 캡처했습니다 + 서클 명단을 캡처했지만 대기 시간이 지나도록 %1$s 항목이 없습니다 + 전달 %1$s KiB · 검사 %2$s KiB · 디코딩 %3$s건 · 경고 %4$s건 · 누락 %5$s건 + 프로토콜 처리 실패: %1$s + 파서 과부하로 대기 중인 청크 %1$s개를 버렸습니다 + 버퍼 한도를 넘은 미식별 서클 연결을 폐기했습니다 + 식별 전 버퍼가 넘친 연결을 식별 후 폐기했습니다 + 서클 정보를 감지했지만 게임 클라이언트를 확인할 수 없습니다 + 서클 식별 정보가 바뀐 연결을 폐기했습니다 + 확인 대기 중인 서클의 식별 정보가 바뀐 연결을 폐기했습니다 + 감지한 서클을 분리해 저장할 수 없습니다 + %3$s에서 %1$s (%2$s) 서클을 감지했습니다 + 새 서클을 감지했습니다. GF2logger로 돌아가 서버를 선택하세요 + 확인 대기열이 가득 차서 새 서클을 폐기했습니다 + 한도를 초과한 확인 대기 서클 캡처를 폐기했습니다 + 대기 중인 서클 확인이 끝나기를 기다리는 중입니다 + 대기 중인 서클을 확인할 수 없습니다 + 선택한 서버가 확인된 클라이언트에 속하지 않습니다 + 이 클라이언트에 다른 서클을 추가하려면 새 캡처를 시작하세요 + 확인한 서클 프로필을 만들 수 없습니다 + 확인한 서클 패킷을 적용할 수 없습니다 + %3$s의 %1$s (%2$s) 서클을 확인했습니다 + 확인되지 않은 서클 패킷을 폐기했습니다 + %2$s 서클 멤버 %1$s명을 저장했습니다 + 서클 CSV를 저장할 수 없습니다 + %1$s 갱신: +%2$s, -%3$s + 완료된 명단 CSV에서 %1$s 서클을 복구했습니다 + 서클 활동 기록을 갱신할 수 없습니다 + 서클 동향 기록을 갱신할 수 없습니다 + 파싱 패킷 기록을 저장할 수 없습니다 + 캡처 준비 중 + 선택한 게임 트래픽 캡처 중 + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 74dce0f..f0a32fe 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -8,7 +8,6 @@ #BDB6AC #44453F #858077 - #F5F0E8 #E49B68 #493529 #5A4030 @@ -32,8 +31,4 @@ #C77F82 #2F181A #D99396 - #2B392D - #A9C9A8 - #40362A - #D9B98D diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 112d950..7aed63a 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -7,23 +7,28 @@ @color/text_primary @color/text_secondary @style/Widget.GF2.TextView - @style/Widget.GF2.Button @style/Widget.GF2.EditText - @color/app_background - @color/app_background false true true @style/GF2DialogTheme + @style/GF2PickerTheme + @style/GF2PickerTheme + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 44fda29..f747f9a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -6,10 +6,9 @@ #EEEAE3 #E5DFD7 #25231F - #6C665F + #625C55 #DED8CF #918A81 - #25231F #D7824B #F6E4D7 #EDD3C1 @@ -19,12 +18,12 @@ #E4EEE3 #D4E3D3 #3E6747 - #4B7955 + #3E6747 #F4E8D2 #7D5D34 - #966A3B - #A85258 - #A8753F + #7D5D34 + #8E3F45 + #8A5A24 #2426241F #D7824B #2B1D14 @@ -33,8 +32,4 @@ #D98A8A #C77777 #321A1B - #E2EBDD - #3F6847 - #F3E6CE - #806039 diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 6b750ae..3ca9c5a 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,5 +1,21 @@ - - + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 475f923..d052e27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,43 +1,28 @@ GF2logger - Capture workspace Data tools Capture status Ready Capturing - Capture is stopped - Supported client IDs - Package ID - Package ID - com.haoplay.game.and.exilium\nHaoPlay client\n\ncom.Sunborn.SnqxExilium.Glo\nDarkwinter client Home Platoon Weekly - View all - On-device protocol capture for installed HaoPlay and Darkwinter clients. Raw traffic is not stored. - Prepare capture - One-time Capture - %1$s Platoon identity\n%2$s Platoon roster\n%3$s Activity evidence\n%4$s Membership updates + Continuous + One-time capture Stop - Open payload options + Open settings Export Platoon backup - Import Platoon backup - This replaces management data for the Platoon contained in the backup. Other Platoons are unchanged. Capture must remain stopped. - Stop capture before exporting or importing a backup. + Backups and restores wait until capture stops, so no packet lands halfway through. Platoon backup exported - Platoon backup restored - Unable to export or restore the Platoon backup - This is not a valid Platoon backup file. + Unable to export the Platoon backup Language English - Korean 한국어 Appearance System Light Dark - Use the device theme, or keep GF2logger in light or dark mode. Skip Back Next @@ -47,36 +32,34 @@ Welcome to Main page Capture communications from the GIRLS\' FRONTLINE 2: EXILIUM client and see packet collection progress at a glance. - Prepare capture keeps running until you stop it. + Continuous capture keeps running until you stop it. One-time Capture checks Platoon identity, Members, Activity, and Updates, then stops automatically. - Recent and saved packet history is stored locally. + Recent and saved packets stay on this phone, under the Recent and Saved tabs. Android will show its VPN consent screen. GF2logger observes communications only for GIRLS\' FRONTLINE 2: EXILIUM, does not decrypt TLS, and never stores raw network packets. Settings Adjust what appears on screen and manage information stored on the device or sent outside it. Choose the language and theme. - Create or restore a complete backup. + Back up or restore a Platoon, with or without app settings. Configure a Discord webhook and select the packet types you need. Platoon management Captured or imported member data is reflected in the selected Platoon’s member records and weekly table. Search and manage current or withdrawn members and their join and withdrawal history. Preview CSV imports before commit and use the automatic checkpoint to undo the latest import. - Switch between isolated Platoons, compare snapshots, or export only selected members. + Switch between isolated Platoons, compare snapshots, or long-press members to export them. Weekly table Review weekly merit tables separated into Standard and Gunsmoke weeks. Tap any cell to see the basis for its value and the packet evidence used. - Manually correct merit or Gunsmoke values, and save or share the weekly table with the Share button. + Edit merit or Gunsmoke values with the pencil button, and save or share the table with the share button. Cut-off settings highlight cells that are or may remain below your selected targets. Parsed packet pages Open a history entry to inspect or export its table or complete decoded CSV text. - Show raw CSV reveals the original decoded content; Copy places it on the clipboard. - Send original CSV sends it through the configured Discord webhook. + Switch between the table and the raw decoded text; copy places it on the clipboard. + Send the original CSV to your Discord webhook from the menu. Use the information in each packet to complete member records and the weekly table over time. Backup Restore backup - Restore app settings and all management data for the Platoon contained in a complete backup. Other Platoons remain unchanged. Back up all information Save app settings and all management data for the selected Platoon, including membership history and weekly tables. - This replaces app settings and data for the Platoon contained in the backup, then selects it. Other Platoons remain unchanged. Keep capture stopped while restoring. This is not a valid complete backup file. Complete backup saved Complete backup restored @@ -87,64 +70,50 @@ Weekly task table Compare latest snapshots Export selected members - Select %1$s Select one or more members first Selected members exported Search name or UID - Active + Withdrawn Active - Sort: name - Sort: Merit This Week - Sort: Total Merit - Sort: last login + Name + Merit This Week + Total Merit + Last login No Platoon Members snapshot has been captured yet. - %1$d active · %2$d withdrawn\nLast snapshot: %3$s (%4$s) No matching members - Merit This Week + Merit this week Total Merit Snapshot comparison Capture at least two complete Platoon Members snapshots to compare changes. %1$s → %2$s Joined Withdrew - Member changes Gunsmoke Frontline score None Member details Active member Withdrawn member - #%1$d · %2$s - Member name - Note Nickname Personal notes Save Saved Unable to save Membership history - Add Membership History + Add membership period Add period Delete member - Deleting this member permanently removes saved history, notes, and weekly edits. A future roster packet may add the member again, but the deleted details cannot be recovered. + Deleting this member permanently removes their history, personal notes, and weekly edits. A later roster may add the member again, but the deleted details cannot be recovered. Member deleted Unable to delete member - Manually Add Withdrawn Member - Join: - Withdraw: + Add withdrawn member Notes - Not set Select date Time (optional) - Select a date first Clear date Clear time UID Add - Clear Enter a unique positive UID and nickname, select both dates, and keep withdrawal after join. - Membership period %1$d\nJoin: %2$s\nWithdrawal: %3$s Period %1$d - %1$s → %2$s Delete membership period Delete this join and withdrawal record? This cannot be undone. Continue? Membership period deleted @@ -152,57 +121,39 @@ The only membership period cannot be deleted. Use Delete member to remove this record. Unknown Edit membership dates - Join (yyyy-MM-dd HH:mm), blank if unknown - Withdrawal (yyyy-MM-dd HH:mm), blank if active/unknown Select a join date and keep withdrawal after join; times are optional Gunsmoke Frontline week Standard week - %1$s – %2$s · reset 05:00 (%3$s) Previous week - Current week Next week Loading weekly table… Unable to load the weekly table. Retry - No complete daily snapshot pairs are available for this week. + Capture the Platoon Members page on two different game days, or import roster CSV files, to build this week\'s table. Member - Rank Total - %1$d merit\nLogin %2$s · Patrol %3$s - %1$d merit\n%2$d pt · %3$s attempts\nLogin %4$s · Patrol %5$s - %1$d merit - %1$d merit\n%2$d pt · %3$s attempts Notes Join / Withdraw Join Withdraw - unknown + Unknown date Add a note for the selected game day Add note This week already contains the maximum 128 notes. Delete note - Delete this note? Delete Note deleted Export weekly CSV Weekly CSV exported + Unable to export weekly CSV Export all weekly task tables No weekly task tables are available to export. - Join - Join - Withdraw Settings Choose which decoded payloads are added to future packet history. Existing history is unchanged. - This choice also applies to packet and report labels. - Follow the device, or keep GF2logger in Light or Dark mode. - Game timezone - Daily reset time - Choose the game server region. The next 05:00 server reset is converted to this phone’s timezone. Server region Auto select Not selected — choose before first capture Auto · %1$s - Auto select is available after a platoon profile is detected. Manual timezone Darkwinter Global Darkwinter China @@ -210,7 +161,7 @@ HaoPlay Japan HaoPlay South Korea HaoPlay Asia - New Platoon Detected + New Platoon detected GF2logger verified the game client, but the packet does not identify its server. Choose the server before these packets are saved. Choose this Platoon’s server Until you confirm, these parsed packets remain only in memory. Force-stopping GF2logger discards them. @@ -221,12 +172,9 @@ Canceling server selection may prevent these packets from being saved. Discard this unconfirmed Platoon capture? Manage Platoons Edit a detected Platoon’s server or permanently delete its isolated data. - %1$s · %2$s · ID %3$d Active - Edit client/server Edit server for %1$s - Verified client: %1$s (read-only)\nChoose the server for %2$s. - Stop capture before editing or deleting a Platoon. + Platoon profiles can only be changed while capture is stopped. Unable to update the Platoon server. No data was moved. Delete Platoon Delete %1$s @@ -236,23 +184,20 @@ Unable to finish deleting the Platoon. Cleanup will be retried safely. %1$s · next reset %2$s on this phone (%3$s) Manual · %1$s (phone: %2$s) - Game: %1$s · phone: %2$s Game timezone updated and weekly history rebuilt Unable to complete the timezone change and weekly-history rebuild Move complete app data only through a backup you explicitly choose. Back - %1$s (payloadType = %2$d) - Required and always enabled. %1$s %1$s, payload type %2$s unknown - PLATOON MEMBERS - PLATOON PROFILE - PLATOON ACTIVITY - PLATOON UPDATES - WEAPONS - WEAPON MODS - PUBLIC SKILL ITEMS - FORMATIONS + Platoon members + Platoon profile + Platoon activity + Platoon updates + Weapons + Weapon mods + Public skill items + Formations PLATOON ACTIVITY UPDATES @@ -268,28 +213,17 @@ Owned weapon mods, levels, suit flags, and lock state. Owned public skill items. The id is an inventory-instance ID, not a player UID. Saved team formations and their equipped items. - Recent parsed packets (latest %1$d) - Delete selected history - Save selected history - Saved packets (up to %1$d) - Delete selected saved history - Export latest Platoon CSV - Import Platoon CSV files - CSV import complete: %1$d retained, %2$d current, %3$d historical, %4$d skipped + Export latest roster CSV + Import roster CSV files The selected Platoon CSV files could not be imported No parsed packets captured yet No packets saved yet - Select %1$s - Show raw CSV file Copy parsed packet GF2logger parsed packet Parsed packet copied Preparing capture - Exported %1$s Unable to export Platoon CSV No Platoon CSV has been captured yet - Select one or more history entries first - Select one or more saved entries first Unable to save selected history Selected history is already saved Saved-history limit reached (%1$d) @@ -301,7 +235,7 @@ No completed capture session yet. %1$s\nForwarded: %2$d KiB · inspected: %3$d KiB\nDecoded: %4$d · warnings: %5$d · dropped chunks: %6$d\nUnknown payloads (type:count): %7$s Weekly task table settings - Enable only the cutlines you want to monitor. Values below an enabled cutline are highlighted in yellow. + Turn on only the cutlines you want to watch. Values below an enabled cutline are shown in amber and underlined. Daily cut-off point Weekly cut-off point Merit @@ -311,9 +245,8 @@ Weekly Daily Patrol days Minimum Save settings - Enter valid non-negative cutline values. Member order - Sort the list or long-press and drag the handle beside a member. The order is retained for later weekly tables. + Weekly tables list members in this order. Drag a handle to fine-tune it; changes save automatically. Sort by Direction Join date @@ -321,9 +254,9 @@ Total Merit Ascending Descending - Apply sort + Apply to list Reset member order - Long-press to move %1$s + Drag to move %1$s Edit member order Copy CSV GF2logger snapshot comparison CSV @@ -332,8 +265,8 @@ GF2logger weekly report CSV Weekly CSV copied Merit - Point - Try + Score + Attempts Login Patrol Manually edit weekly task table @@ -343,10 +276,6 @@ Confirm Cancel Weekly task table edits saved - Enter non-negative numbers. Gunsmoke attempts must be between 0 and 3. - ≥ marks the minimum confirmed by captures so far on an unfinished day.\n? means the available captures cannot determine that day.\nCompleted days with multiple compatible 0/50/90 allocations remain unknown; the app never displays an arbitrary estimate.\nIf the skipped period is excessively long, all earlier history may be displayed as ?. - ≥ marks a confirmed minimum; ? means the available captures cannot safely separate that day.\nMissing Gunsmoke days cannot be safely divided into merit, score, attempts, attendance, or Daily Patrol; use the counter-backed Total and manually correct only facts you know.\nIf the skipped period is excessively long, all earlier history may be displayed as ?. - observed %1$s Deleted %1$d history entry Deleted %1$d history entries @@ -363,18 +292,13 @@ Deleted %1$d saved entry Deleted %1$d saved entries - Evidence Health How evidence states work - ✓ exact · ≥ confirmed minimum · ? unknown — tap for details An exact value is confirmed by a closing boundary, direct event, manual edit, or unanimous valid allocation. Captures prove at least this value, but missing evidence may make the final value higher. The available captures cannot determine this field safely, so GF2logger does not guess. Completed days can only use valid daily merit allocations. If several 0/50/90 combinations still fit, the day remains unknown. The counter-backed weekly total remains useful when missing Gunsmoke days cannot be divided safely. Long capture gaps can leave earlier fields unknown. Tap any weekly cell to inspect the exact evidence used. - Observed days %1$d/%2$d · Exact %3$d · Minimum %4$d · Unknown %5$d\nDirect login %6$d days · Direct patrol %7$d days · Closing boundaries %8$d - %1$s · %2$s · %3$s - Answer: %1$s\n\nWhy:\n%2$s Confirmed Confirmed minimum; the final value may be higher Unknown from the available captures @@ -394,20 +318,18 @@ A long gap prevents a unique daily allocation. More than one valid allocation remains, so the app does not guess. Review CSV import - Destination: %1$s (ID %2$d)\n%3$s · %4$s\n\nPayload 21917 cannot verify this destination. Confirm that the selected Platoon is correct. - Validated files: %1$d\nAlready present: %2$d\nHistorical captures: %3$d\nMembers represented: %4$d\nNew member UIDs: %5$d\nName differences: %6$d\nPotential joins: %7$d\nPotential withdrawals: %8$d\nSelected size: %9$d KiB\nCapture range: %10$s\n\nNo data has been changed. Importing creates an automatic checkpoint first. The selected Platoon changed or is no longer available. No CSV data was imported. Select the destination Platoon and try again. %1$s — %2$s Validating CSV files and preparing preview… Creating checkpoint and importing CSV files… - CSV import complete: %1$d retained, %2$d current, %3$d historical, %4$d skipped. Undo is available. + Import done. Files kept: %1$d · current: %2$d · older snapshots: %3$d · skipped: %4$d. You can undo this from Home. Undo last CSV import Restore the automatic checkpoint from immediately before the last CSV import? Undo is refused if Platoon data changed after that import. No CSV import checkpoint is available. Restoring the CSV import checkpoint… The last CSV import was undone. Undo is unavailable, invalid, or blocked because Platoon data changed after the import. - Stop capture before previewing, importing, or undoing CSV files. + CSV import and undo wait until capture stops, so the roster isn\'t written twice. Share weekly table Include member names Include UIDs @@ -431,15 +353,14 @@ Unable to remove the saved Discord webhook. Send original CSV Send this original CSV file to the configured Discord channel? This explicitly moves captured Platoon data outside the app. - Configure a Discord incoming webhook in Options before sending. + Set up a Discord incoming webhook in Settings before sending. Sending original CSV to Discord… Original CSV sent to Discord. Unable to send the original CSV. Check the webhook and network connection. This parsed packet does not contain a valid sendable CSV body. - Table History + Table history Automatically recorded past versions of the current weekly table. No table changes have been recorded yet. - %1$d members Currently restored Table history preview Restore this table @@ -447,21 +368,242 @@ The recorded weekly table was restored. This table history entry is no longer available. Showing a restored table history entry. New evidence will return this week to the live table. - Select platoon + Select Platoon Select Add a new Platoon profile - Enter New Platoon Information + New Platoon details Enter the identity of the Platoon that will receive this CSV. Client Platoon name Platoon ID Check every field. This client, server, and Platoon ID may already be registered. - Forget profile - Remove this platoon from the selectors to recover profile capacity? Its private data is retained and returns if the platoon is detected or restored again. - Forget - Unable to forget this platoon profile - No platoon detected + No Platoon detected No Platoon information has been collected yet. HaoPlay capture server Darkwinter capture server + More options + %1$s · %2$s · ID %3$d + PROFILE + Done + Continue + Server for %1$s + Cancel selection + %1$d joined · %2$d withdrew · %3$d changed + Compared snapshots + Current + Date: %1$s + Time: %1$s + A member with this UID already exists. + Exact time from in-game Updates · read-only + All + Withdrawn + Withdrawal must be after the join date. + Select both the join and withdrawal dates. + Enter a nickname. + Enter a positive numeric UID. + Last login + Login %1$s + Level + Lv. %1$d + Comparing snapshots… + Loading members… + %1$s: merit this week +%2$d, total merit +%3$d, Gunsmoke score +%4$d + Counter changes · %1$d + Shown everywhere in place of the in-game name. + %1$d of %2$d, %3$s + Member order reset + Private to this device. Excluded from shared images unless you include it. + Personal notes about this member + Profile + %1$s → %2$s\nJoin: %3$s · Withdraw: %4$s + This period overlaps another period or leaves two periods open. + Unable to save. Periods cannot overlap, and only one can stay open. + This week + Move down + Move up + No active members + No membership history + Run a capture in the game\'s Platoon › Members page, or import a roster CSV from Home. + Unable to load Platoon data. + Present + Reset + Return to the default join-date order? Your custom order will be removed. + Select all + Select members + Selected + Sort by + Sort the whole list + legacy import + manual + observed in roster + exact (Updates) + Active + Last snapshot + Withdrawn + Numeric player UID + + %1$d member + %1$d members + + + %1$d selected + %1$d selected + + Apply + at least %1$s + 0–3 · blank clears + below cutline + Leave blank to clear + Cancel editing + Auto + Clear manual values + Enter a whole number of 0 or more. + 0–%1$d + Enter a whole number from 0 to %1$d. + Delete note: %1$s + Your unsaved changes to this table will be lost. + Discard changes? + Editing table + Edited + All values confirmed + Login and Daily Patrol: ✓ yes, × confirmed no, - not observed, ? undetermined. + Observed %1$d/%2$d days\nExact %3$d · minimum %4$d · unknown %5$d + Some values are minimums or unknown + unknown + This week + Guide + Attempts must be between 0 and 3. + Enter a whole number of 0 or more, or leave it blank. + Check the value for %1$s on %2$s. + L + Only change values you can confirm in the game. Untouched fields keep their captured evidence. + No + No joins or withdrawals this week. + No notes for this week. + No table for this week yet + not observed + Note added + Game day + Enter a note. + P + Evidence: %1$d/%2$d days observed · exact %3$d · minimum %4$d · unknown %5$d + Rank %1$d + Choose a week + Sensitive. Off by default. + Show live table + Start editing + Totals are calculated from the daily cells. + Save your changes before leaving? + Unsaved changes + %1$s – %2$s + Reset 05:00 · %1$s + %1$s, %2$s + %1$s: %2$s + #%1$d + %1$s (#%2$d) + %1$s · %2$s + Tap a day to change its values. Edited cells are highlighted until you save. + This is a recorded version of the table. Restoring shows it until new evidence changes the table. + This recorded table is no longer available. + Table history (%1$d) + Yes + + %1$d cell changed + %1$d cells changed + + Global + China + Japan + South Korea + Asia + Active Platoon + Version + Backup and restore + Export or restore Platoon data in Settings + Saving backup… + Restoring backup… + Waiting for Android to start the VPN. + Open the game\'s Platoon › Members and Updates pages while capture runs. + captured + Change + Platoon activity + Platoon identity + Member roster + Membership updates + Choose file + Installed + Not installed + Copied + Copy + Roster CSV files do not identify their Platoon. Choose where these files belong. + Destination + Import into which Platoon? + Roster files don\'t record which Platoon they came from. Make sure this is the right one. + Nothing has changed yet. Importing creates an automatic checkpoint first, so you can undo it. + Already imported + Valid files + Older than current data + Possible joins + Members in files + Name differences + New UIDs + Capture range + Total size + Possible withdrawals + Delete packets? + Send an original CSV to your own Discord channel + Spreadsheet-safe copy of the newest roster + Members, history, and weekly tables of the selected Platoon + Import + Preview first; an automatic checkpoint allows undo + %1$d KiB + Traffic and parser counters from the last session + Opening packet… + Packet history appears after a Platoon is detected. + Packet history + This packet is no longer available. + Raw text + Table + Numeric Platoon ID + Updating the reset time and rebuilding weekly history… + Recent %1$d/%2$d + Required + Replaces only the Platoon inside the backup + Restoring replaces the data of the Platoon inside the backup and then selects it. A complete backup also replaces app settings. Other Platoons are unchanged. + Roster CSV exported + Save + Saved %1$d/%2$d + Send + Set up + About + Capture + The server is used when a new Platoon is first detected on each client. + Display + Platoon + Show all (%1$d) + Show the welcome guide + Undo + Restore the checkpoint taken before the last import + waiting + + Delete %1$d packet? This cannot be undone. + Delete %1$d packets? This cannot be undone. + + First seen %1$s + Platoon backup restored + GF2logger — %1$s + Member %1$d + Watching %1$s. Open the Platoon pages in the game. + %1$s and %2$s + Today, %1$s %2$s + Latest roster · %1$s + joined %1$d + withdrew %1$d + Saving this Platoon… + Clear search + This replaces the current order, including any members you moved by hand. + Apply, next day + Capture is running + Stop capture + Patrol can\'t be yes while login is no. diff --git a/app/src/main/res/values/strings_capture.xml b/app/src/main/res/values/strings_capture.xml new file mode 100644 index 0000000..f3cd7ae --- /dev/null +++ b/app/src/main/res/values/strings_capture.xml @@ -0,0 +1,45 @@ + + + Preparing capture + Capture is already running + Capturing only %1$s + The native forwarding core is unavailable in this build + Unable to import previous Platoon history + Install a supported HaoPlay or Darkwinter client first + Unable to establish VPN: %1$s + VPN permission was revoked + Native forwarding core failed to start + Capture stopped unexpectedly. Start capture again to retry. + Captured Platoon roster, activity, and updates + Captured Platoon roster; missing %1$s after the navigation period + Forwarded %1$s KiB; inspected %2$s KiB; decoded %3$s payloads; warnings %4$s; dropped %5$s + Protocol processing failed: %1$s + Parser overloaded; dropped %1$s queued chunks + Discarded an unidentified Platoon flow that exceeded its buffer + Discarded an identified flow whose pre-identity buffer overflowed + Detected a Platoon profile, but its game client could not be verified + Discarded a flow whose Platoon identity changed + Discarded a flow whose pending Platoon identity changed + Unable to isolate the detected Platoon + Detected %1$s (%2$s) via %3$s + New Platoon detected; return to GF2logger and choose its server + Discarded a new Platoon because the confirmation queue is full + Discarded an oversized pending Platoon capture + Waiting for the pending Platoon confirmation to finish + Unable to confirm the pending Platoon + The selected server does not belong to the verified client + Start a new capture before adding another Platoon for this client + Unable to create the confirmed Platoon profile + Unable to apply the confirmed Platoon packets + Confirmed %1$s (%2$s) via %3$s + Discarded the unconfirmed Platoon packets + Saved %1$s members for %2$s + Unable to save Platoon CSV + Updated %1$s: +%2$s, -%3$s + Recovered %1$s from the completed roster CSV + Unable to update Platoon activity history + Unable to update exact Platoon history + Unable to save parsed-packet history + Preparing capture + Capturing selected game traffic + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 6fce432..135cf21 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -9,20 +9,19 @@ @color/text_primary @color/text_secondary @style/Widget.GF2.TextView - @style/Widget.GF2.Button @style/Widget.GF2.EditText - @color/app_background - @color/app_background true true true @style/GF2DialogTheme + @style/GF2PickerTheme + @style/GF2PickerTheme - + + + diff --git a/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt b/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt index e9400e9..2bd4901 100644 --- a/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt +++ b/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt @@ -31,4 +31,10 @@ class OptionsBackupFailureMessageTest { fullBackupRestoreFailureMessage(IllegalArgumentException("local restore state")), ) } + + @Test + fun `export failures name the backup kind that failed`() { + assertEquals(R.string.full_backup_export_failed, backupExportFailureMessage(complete = true)) + assertEquals(R.string.status_backup_export_failed, backupExportFailureMessage(complete = false)) + } } diff --git a/app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt new file mode 100644 index 0000000..58125eb --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt @@ -0,0 +1,79 @@ +package dev.gf2log.app + +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyEditSessionTest { + private val week = LocalDate.of(2026, 10, 12) + private val nextWeek = week.plusDays(7) + + @Test + fun `edits are ignored outside edit mode`() { + val session = WeeklyEditSession() + + assertFalse(session.put(CellEdit(uid = 1, gameDay = week, merit = "90"))) + assertTrue(session.isEmpty()) + } + + @Test + fun `an empty edit restores the cell instead of being kept`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "90")) + session.put(CellEdit(uid = 1, gameDay = week)) + + assertNull(session[1, week]) + assertEquals(0, session.size) + } + + @Test + fun `a later edit of the same cell replaces the earlier one`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "50")) + session.put(CellEdit(uid = 1, gameDay = week, merit = "90", attended = Choice.YES)) + + assertEquals(1, session.size) + assertEquals("90", session[1, week]?.merit) + } + + @Test + fun `member edits are keyed by game day`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "10")) + session.put(CellEdit(uid = 1, gameDay = week.plusDays(1), merit = "20")) + session.put(CellEdit(uid = 2, gameDay = week, merit = "30")) + + assertEquals(setOf(week, week.plusDays(1)), session.forMember(1).keys) + assertEquals("30", session.forMember(2).getValue(week).merit) + } + + @Test + fun `rendering another week discards the draft`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "90")) + + session.retainFor(week) + assertTrue(session.isEditing(week)) + assertEquals(1, session.size) + + session.retainFor(nextWeek) + assertNull(session.period) + assertTrue(session.isEmpty()) + } + + @Test + fun `starting again clears the previous draft`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "90")) + + session.start(week) + + assertTrue(session.isEditing(week)) + assertTrue(session.isEmpty()) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt deleted file mode 100644 index 5a29fd2..0000000 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.gf2log.app.capture - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class CaptureFlowStateCleanupTest { - @Test - fun metadataIsRemovedWhenFlowNeverCreatedAParser() { - val parsers = mutableMapOf() - val metadata = mutableMapOf(7L to metadata()) - - assertNull(CaptureFlowStateCleanup.remove(7L, parsers, metadata)) - assertFalse(metadata.containsKey(7L)) - } - - @Test - fun parserAndMetadataAreRemovedTogether() { - val parsers = mutableMapOf(7L to "parser") - val metadata = mutableMapOf(7L to metadata()) - - assertEquals("parser", CaptureFlowStateCleanup.remove(7L, parsers, metadata)) - assertFalse(parsers.containsKey(7L)) - assertFalse(metadata.containsKey(7L)) - } - - @Test - fun queuedOpenCannotRestoreMetadataAfterRejectedCloseQuarantinesFlow() { - val metadata = mutableMapOf() - - assertFalse( - CaptureFlowStateCleanup.registerUnlessQuarantined( - flowId = 7L, - value = metadata(), - metadata = metadata, - quarantinedFlows = setOf(7L), - ), - ) - assertFalse(metadata.containsKey(7L)) - } - - @Test - fun liveFlowMetadataIsRegisteredNormally() { - val metadata = mutableMapOf() - - assertTrue( - CaptureFlowStateCleanup.registerUnlessQuarantined( - flowId = 7L, - value = metadata(), - metadata = metadata, - quarantinedFlows = emptySet(), - ), - ) - assertTrue(metadata.containsKey(7L)) - } - - private fun metadata() = CaptureFlowMetadata(6, "10.0.0.2", 1, "10.0.0.3", 2, null) -} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt new file mode 100644 index 0000000..7527b1f --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt @@ -0,0 +1,69 @@ +package dev.gf2log.app.capture + +import dev.gf2log.protocol.Gfl2PayloadDecoder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CaptureRunTrackerTest { + @Before + fun setUp() { + MainThread.testExecutor = { it() } + CaptureStatus.beginSession(guided = true) + } + + @After + fun tearDown() { + CaptureStatus.beginSession(guided = false) + MainThread.testExecutor = null + } + + @Test + fun oneTimeCaptureCompletesOnlyForTheFirstScope() { + var completed = 0 + var rosters = 0 + val tracker = CaptureRunTracker( + captureOnce = true, + onComplete = { completed += 1 }, + onTargetRosterCaptured = { rosters += 1 }, + ) + + tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) + assertNull(tracker.missingAfterRoster()) + tracker.rosterCaptured("second") + tracker.mark("second", Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) + tracker.mark("second", Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) + assertEquals(0, rosters) + + tracker.rosterCaptured("first") + assertEquals(1, rosters) + assertEquals(listOf("ACTIVITY", "UPDATES"), tracker.missingAfterRoster()) + assertEquals(0, completed) + + tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) + tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) + assertEquals(1, completed) + assertEquals(emptyList(), tracker.missingAfterRoster()) + assertEquals( + CaptureRunTracker.REQUIRED_TYPES, + CaptureStatus.snapshot().guided?.capturedPayloadTypes, + ) + } + + @Test + fun continuousCaptureMarksEveryScopeWithoutStopping() { + var completed = 0 + val tracker = CaptureRunTracker(captureOnce = false, onComplete = { completed += 1 }) + + CaptureRunTracker.REQUIRED_TYPES.forEach { tracker.mark("any", it) } + + assertEquals(0, completed) + assertTrue( + CaptureStatus.snapshot().guided?.capturedPayloadTypes.orEmpty() + .containsAll(CaptureRunTracker.REQUIRED_TYPES), + ) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt index 6f00d6d..12e4ca8 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -1,51 +1,102 @@ package dev.gf2log.app.capture import dev.gf2log.protocol.Gfl2PayloadDecoder +import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit class CaptureStatusTest { - @Test - fun tracksOnlyTheCurrentApplicationProcess() { + @Before + fun setUp() { + MainThread.testExecutor = { it() } CaptureStatus.markStopped() - assertFalse(CaptureStatus.isRunning) - assertFalse(CaptureStatus.isStarting) - assertEquals("Capture is stopped", CaptureStatus.read()) + CaptureStatus.beginSession(guided = false) + } + + @After + fun tearDown() { + MainThread.testExecutor = null + } + + @Test + fun tracksPhaseNoticeAndTargetsAsOneSnapshot() { + assertEquals(CapturePhase.STOPPED, CaptureStatus.snapshot().phase) + assertNull(CaptureStatus.snapshot().notice) CaptureStatus.markStarting() - assertFalse(CaptureStatus.isRunning) - assertTrue(CaptureStatus.isStarting) - assertEquals("Preparing capture", CaptureStatus.read()) + assertEquals(CapturePhase.STARTING, CaptureStatus.snapshot().phase) + assertEquals(CaptureNotice.PREPARING, CaptureStatus.snapshot().notice?.notice) - CaptureStatus.markRunning("Capturing selected package") - CaptureStatus.update("Decoded Platoon members") + CaptureStatus.markRunning(listOf("pkg.one", "pkg.two")) + CaptureStatus.report(CaptureNotice.ROSTER_SAVED, "12", "Owls") + val running = CaptureStatus.snapshot() assertTrue(CaptureStatus.isRunning) - assertFalse(CaptureStatus.isStarting) - assertEquals("Decoded Platoon members", CaptureStatus.read()) + assertEquals(listOf("pkg.one", "pkg.two"), running.capturedTargets) + assertEquals(CaptureNotice.ROSTER_SAVED, running.notice?.notice) + assertEquals(listOf("12", "Owls"), running.notice?.args) - CaptureStatus.markStopped("Capture failed") - assertFalse(CaptureStatus.isRunning) - assertFalse(CaptureStatus.isStarting) - assertEquals("Capture failed", CaptureStatus.read()) + CaptureStatus.markStopped(CaptureNotice.NATIVE_START_FAILED) + val stopped = CaptureStatus.snapshot() + assertEquals(CapturePhase.STOPPED, stopped.phase) + assertEquals(emptyList(), stopped.capturedTargets) + assertEquals(CaptureNotice.NATIVE_START_FAILED, stopped.notice?.notice) } @Test fun exposesImmutableGuidedCaptureProgressUntilTheNextSession() { CaptureStatus.beginSession(guided = true) CaptureStatus.markUsefulPayload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) - val progress = requireNotNull(CaptureStatus.readGuidedProgress()) + val progress = requireNotNull(CaptureStatus.snapshot().guided) assertEquals( setOf(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS), progress.capturedPayloadTypes, ) - CaptureStatus.markStopped("Guided capture complete") - assertEquals(progress, CaptureStatus.readGuidedProgress()) + CaptureStatus.markStopped(CaptureNotice.CAPTURE_ONCE_COMPLETE) + assertEquals(progress, CaptureStatus.snapshot().guided) CaptureStatus.beginSession(guided = false) - assertNull(CaptureStatus.readGuidedProgress()) + assertNull(CaptureStatus.snapshot().guided) + } + + @Test + fun concurrentPayloadMarksAreNeverLost() { + CaptureStatus.beginSession(guided = true) + val types = (1..64).toList() + val pool = Executors.newFixedThreadPool(8) + val done = CountDownLatch(types.size) + types.forEach { type -> + pool.execute { + CaptureStatus.markUsefulPayload(type) + CaptureStatus.report(CaptureNotice.TRAFFIC, "$type", "0", "0", "0", "0") + done.countDown() + } + } + assertTrue(done.await(10, TimeUnit.SECONDS)) + pool.shutdown() + + assertEquals(types.toSet(), CaptureStatus.snapshot().guided?.capturedPayloadTypes) + } + + @Test + fun listenersReceiveTheCurrentSnapshotImmediatelyAndAfterChanges() { + val received = mutableListOf() + val listener: (CaptureSnapshot) -> Unit = { received += it } + + CaptureStatus.addListener(listener) + assertEquals(CapturePhase.STOPPED, received.single().phase) + + CaptureStatus.markRunning(listOf("pkg")) + assertEquals(CapturePhase.RUNNING, received.last().phase) + + CaptureStatus.removeListener(listener) + CaptureStatus.markStopped() + assertEquals(2, received.size) } } diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt new file mode 100644 index 0000000..70bbeff --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt @@ -0,0 +1,32 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant + +class CaptureTrafficStatsTest { + @Test + fun trafficIsReportedOncePerBucketAndDiagnosticsOncePerSession() { + val stats = CaptureTrafficStats() + assertNull(stats.finish("never started")) + + stats.begin(Instant.EPOCH) + stats.recordParsed(decoded = 2, warnings = 1) + stats.recordUnknown(9) + stats.recordUnknown(9) + stats.recordUnknown(3) + stats.recordDropped() + + assertNull(stats.recordTraffic(forwarded = 1_000, inspected = 10)) + assertEquals(listOf("64", "1", "2", "1", "1"), stats.recordTraffic(64 * 1024, 1_024)) + assertNull(stats.recordTraffic(65 * 1024, 1_024)) + + val diagnostics = requireNotNull(stats.finish("done", Instant.ofEpochSecond(5))) + assertEquals(Instant.EPOCH, diagnostics.startedAt) + assertEquals(2L, diagnostics.decodedPayloads) + assertEquals("3:1, 9:2", diagnostics.unknownPayloads) + assertEquals("done", diagnostics.finalStatus) + assertNull(stats.finish("again")) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.kt new file mode 100644 index 0000000..ca68e3b --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.kt @@ -0,0 +1,56 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class CaptureWorkerTest { + @Test + fun chunkBudgetRejectsOverflowButNeverLifecycleTasks() { + val failures = mutableListOf() + val worker = CaptureWorker("test-worker", chunkCapacity = 2) { failures += it } + val gate = CountDownLatch(1) + val order = Collections.synchronizedList(mutableListOf()) + try { + assertTrue(worker.execute { gate.await(5, TimeUnit.SECONDS) }) + assertTrue(worker.submitChunk { order += "chunk-1" }) + assertTrue(worker.submitChunk { order += "chunk-2" }) + assertFalse(worker.submitChunk { order += "dropped" }) + assertTrue(worker.execute { order += "teardown" }) + + gate.countDown() + val drained = CountDownLatch(1) + worker.execute { drained.countDown() } + assertTrue(drained.await(5, TimeUnit.SECONDS)) + + assertEquals(listOf("chunk-1", "chunk-2", "teardown"), order) + assertTrue(worker.submitChunk { order += "chunk-3" }) + } finally { + worker.shutdown() + } + assertTrue(failures.isEmpty()) + } + + @Test + fun failingTasksAreReportedAndReleaseTheirBudget() { + val failures = Collections.synchronizedList(mutableListOf()) + val worker = CaptureWorker("test-worker", chunkCapacity = 1) { failures += it } + try { + repeat(3) { + val finished = CountDownLatch(1) + assertTrue(worker.submitChunk { error("boom") }) + worker.execute { finished.countDown() } + assertTrue(finished.await(5, TimeUnit.SECONDS)) + } + assertEquals(3, failures.size) + } finally { + worker.shutdown() + } + assertFalse(worker.execute {}) + assertFalse(worker.submitChunk {}) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt index f3441bc..35eaa89 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -10,6 +10,7 @@ import java.time.Instant import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -20,14 +21,14 @@ class GuildMembersCsvWriterTest { val temporaryFolder = TemporaryFolder() @Test - fun continuationPayloadsShareOneCsvAndLogTime() { + fun completedRosterIsWrittenOnceWithItsLogTime() { val output = temporaryFolder.newFolder("guild-members") val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09.987Z"), ZoneOffset.UTC) val writer = GuildMembersCsvWriter(output, clock) - writer.accept(payload(messageId = 0, uid = 123456u, name = "First Member", end = true)) - writer.accept(payload(messageId = 42, uid = 654321u, name = "Second Member", end = true)) - writer.close() + writer.accept( + payload(42, end = true, member(123456u, "First Member"), member(654321u, "Second Member")), + ) val file = output.listFiles().orEmpty().single() assertEquals("gf2log_platoonmembers_20260721T191109Z.csv", file.name) @@ -41,6 +42,22 @@ class GuildMembersCsvWriterTest { ) } + @Test + fun rosterCutShortByAnotherPayloadIsNeverMergedWithALaterRoster() { + val output = temporaryFolder.newFolder("boundary") + val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) + val completed = mutableListOf() + val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) + + // The parser emits a message-id-0 roster only when a boundary ended it unterminated. + assertNull(writer.accept(payload(messageId = 0, uid = 1u, name = "Stale", end = true))) + assertTrue(output.listFiles().orEmpty().isEmpty()) + val saved = writer.accept(payload(messageId = 42, uid = 2u, name = "Current", end = true)) + + assertEquals(1, saved?.rowCount) + assertEquals(listOf("Current"), completed.single().members.map { it.name }) + } + @Test fun completedBatchesWithinOneSecondUseDistinctFiles() { val output = temporaryFolder.newFolder("same-second-batches") @@ -49,7 +66,6 @@ class GuildMembersCsvWriterTest { writer.accept(payload(messageId = 41, uid = 1u, name = "First", end = true)) writer.accept(payload(messageId = 42, uid = 2u, name = "Second", end = true)) - writer.close() assertEquals( listOf( @@ -60,32 +76,6 @@ class GuildMembersCsvWriterTest { ) } - @Test - fun flowCloseDiscardsMessageZeroContinuationWithoutProtocolCompletion() { - val output = temporaryFolder.newFolder("flow-ended-batches") - val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) - val completed = mutableListOf() - val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) - - writer.accept( - payload(messageId = 0, uid = 1u, name = "First", end = true), - flowEnded = true, - ) - writer.accept(payload(messageId = 42, uid = 2u, name = "Second", end = true)) - writer.close() - - val files = output.listFiles().orEmpty().sortedBy { it.name } - assertEquals(1, files.size) - assertEquals(1, completed.size) - assertEquals( - listOf( - GuildMembersCsv.HEADER, - "2,Second,60,120,4560,789,1234,1700000000,2026-07-21T19:11:09Z", - ), - files.single().readLines(Charsets.UTF_8), - ) - } - @Test fun completionCallbackReceivesTheWholeDeduplicatedBatch() { val output = temporaryFolder.newFolder("completed-batch") @@ -93,9 +83,10 @@ class GuildMembersCsvWriterTest { val completed = mutableListOf() val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) - writer.accept(payload(messageId = 0, uid = 1u, name = "Old", end = true)) - writer.accept(payload(messageId = 44, uid = 1u, name = "Current", end = true)) + val saved = writer.accept(payload(44, end = true, member(1u, "Old"), member(1u, "Current"))) + assertEquals(1, saved?.rowCount) + assertEquals(2, saved?.file?.readLines(Charsets.UTF_8)?.size) assertEquals(1, completed.size) assertEquals("2026-07-21T19:11:09Z", completed.single().logTime) assertEquals("Current", completed.single().members.single().name) @@ -107,28 +98,6 @@ class GuildMembersCsvWriterTest { assertEquals("Current", parsed.members.single().name) } - @Test - fun incompleteBatchIsNotPublishedAsCsvUntilProtocolCompletion() { - val output = temporaryFolder.newFolder("publish-on-completion") - val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) - val writer = GuildMembersCsvWriter(output, clock) - - val partial = writer.accept(payload(messageId = 0, uid = 1u, name = "First", end = false)) - - assertEquals(null, partial) - assertTrue(output.listFiles().orEmpty().none { it.extension == "csv" }) - assertEquals(1, output.listFiles().orEmpty().count { it.extension == "partial" }) - - val completed = writer.accept( - payload(messageId = 42, uid = 2u, name = "Second", end = true), - ) - - assertEquals("gf2log_platoonmembers_20260721T191109Z.csv", completed?.file?.name) - assertEquals(2, completed?.rowCount) - assertTrue(output.listFiles().orEmpty().none { it.extension == "partial" }) - assertEquals(1, output.listFiles().orEmpty().count { it.extension == "csv" }) - } - @Test fun transientPublicationFailureRetriesImmediately() { val output = temporaryFolder.newFolder("publication-failure") @@ -197,20 +166,6 @@ class GuildMembersCsvWriterTest { assertEquals(1, output.listFiles().orEmpty().count { it.extension == "csv" }) } - @Test - fun shutdownDiscardsAnUnterminatedContinuation() { - val output = temporaryFolder.newFolder("incomplete-batch") - val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) - val completed = mutableListOf() - val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) - - writer.accept(payload(messageId = 0, uid = 1u, name = "Partial", end = false)) - writer.close() - - assertTrue(completed.isEmpty()) - assertTrue(output.listFiles().orEmpty().isEmpty()) - } - @Test fun completedEmptyPayloadDeletesHeaderOnlyCsv() { val output = temporaryFolder.newFolder("empty-completed-batch") @@ -282,11 +237,14 @@ class GuildMembersCsvWriterTest { } private fun payload(messageId: Int, uid: UInt, name: String, end: Boolean): ParsedPayload = + payload(messageId, end, member(uid, name)) + + private fun payload(messageId: Int, end: Boolean, vararg members: GuildMember): ParsedPayload = ParsedPayload( messageId = messageId, payloadType = Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, isEndOfMessage = end, - data = GuildMembersData(listOf(member(uid, name))), + data = GuildMembersData(members.toList()), ) private fun member(uid: UInt, name: String) = GuildMember( diff --git a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt index 8a7e125..3c01461 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -14,7 +14,69 @@ import java.time.Instant class PendingPlatoonAdmissionStoreTest { @After - fun tearDown() = PendingPlatoonAdmissionStore.clearForTests() + fun tearDown() { + PendingPlatoonAdmissionStore.clearForTests() + MainThread.testExecutor = null + } + + @Test + fun listenersSeeVisibleCandidatesChangeWithoutPolling() { + MainThread.testExecutor = { it() } + val observed = mutableListOf>() + PendingPlatoonAdmissionStore.addListener { summaries -> + observed += summaries.map { it.profile.platoonName } + } + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(5u, "Owls"), + flowId = 1L, + ).token, + ) + PendingPlatoonAdmissionStore.offer(token, 1L, payload(profile(5u, "Owls"))) + PendingPlatoonAdmissionStore.claim(token) + PendingPlatoonAdmissionStore.releaseClaim(token) + PendingPlatoonAdmissionStore.discard(token) + + assertEquals( + listOf(emptyList(), listOf("Owls"), emptyList(), listOf("Owls"), emptyList()), + observed, + ) + } + + @Test + fun totalPayloadBoundIsSharedAcrossCandidatesAndReleasedOnRemoval() { + val tokens = (1..3).map { index -> + requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(index.toUInt(), "P$index"), + flowId = index.toLong(), + ).token, + ) + } + repeat(PendingPlatoonAdmissionStore.MAX_PAYLOADS_PER_CANDIDATE) { + PendingPlatoonAdmissionStore.offer(tokens[0], 1L, payload(profile(1u, "P1"))) + PendingPlatoonAdmissionStore.offer(tokens[1], 2L, payload(profile(2u, "P2"))) + } + assertTrue( + PendingPlatoonAdmissionStore.offer(tokens[2], 3L, payload(profile(3u, "P3"))) is + PendingPlatoonAdmissionStore.OfferResult.Overflow, + ) + + PendingPlatoonAdmissionStore.discard(tokens[0]) + val retry = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(3u, "P3"), + flowId = 3L, + ).token, + ) + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Accepted, + PendingPlatoonAdmissionStore.offer(retry, 3L, payload(profile(3u, "P3"))), + ) + } @Test fun candidateIsMemoryOnlyBoundedAndClaimedAtomically() { @@ -46,6 +108,34 @@ class PendingPlatoonAdmissionStoreTest { assertFalse(PendingPlatoonAdmissionStore.contains(token)) } + @Test + fun failedOrThrowingAdmissionReleasesTheClaimForAnotherDecision() { + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(5u, "Owls"), + flowId = 4L, + ).token, + ) + + assertNull(PendingPlatoonAdmissionStore.withClaim(token) { false }) + assertNotNull(PendingPlatoonAdmissionStore.summary(token)) + + assertTrue( + runCatching { + PendingPlatoonAdmissionStore.withClaim(token) { error("registry unavailable") } + }.isFailure, + ) + assertNotNull(PendingPlatoonAdmissionStore.summary(token)) + + assertEquals(setOf(4L), PendingPlatoonAdmissionStore.withClaim(token) { claim -> + assertNull(PendingPlatoonAdmissionStore.summary(claim.summary.token)) + true + }) + assertFalse(PendingPlatoonAdmissionStore.contains(token)) + assertNull(PendingPlatoonAdmissionStore.withClaim(token) { true }) + } + @Test fun repeatedIdentitySharesOnePromptWithoutMixingOtherClients() { val first = requireNotNull( diff --git a/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt b/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt index 34018ef..082a5f0 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt @@ -16,10 +16,10 @@ import org.junit.Test class PlatoonPayloadDispatcherTest { @Test - fun routesRequiredPayloadsAndAlwaysSignalsTheRosterWriter() { + fun routesEachRequiredPayloadOnlyToItsHandler() { val routed = mutableListOf() val dispatcher = PlatoonPayloadDispatcher( - onMembers = { _, _ -> routed += "members"; null }, + onMembers = { _ -> routed += "members"; null }, onActivity = { routed += "activity"; true }, onUpdates = { routed += "updates"; true }, ) @@ -29,32 +29,32 @@ class PlatoonPayloadDispatcherTest { val updates = dispatcher.dispatch(updatesPayload()) assertEquals( - listOf("members", "activity", "members", "updates", "members"), + listOf("members", "activity", "updates"), routed, ) - assertTrue(members.members.getOrThrow() == null) + assertTrue(members.members?.getOrThrow() == null) assertNull(members.activity) assertNull(members.updates) assertTrue(activity.activity?.getOrThrow() == true) - assertTrue(activity.members.getOrThrow() == null) + assertNull(activity.members) assertTrue(updates.updates?.getOrThrow() == true) - assertTrue(updates.members.getOrThrow() == null) + assertNull(updates.members) } @Test - fun handlerFailureDoesNotSuppressTheRosterBoundarySignal() { + fun handlerFailureIsReturnedInsteadOfThrown() { val routed = mutableListOf() val dispatcher = PlatoonPayloadDispatcher( - onMembers = { _, _ -> routed += "members"; null }, + onMembers = { _ -> routed += "members"; null }, onActivity = { routed += "activity"; error("database unavailable") }, onUpdates = { routed += "updates"; true }, ) val result = dispatcher.dispatch(activityPayload()) - assertEquals(listOf("activity", "members"), routed) + assertEquals(listOf("activity"), routed) assertTrue(result.activity?.isFailure == true) - assertTrue(result.members.isSuccess) + assertNull(result.members) assertNull(result.updates) } diff --git a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt index 03b6c72..20448df 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt @@ -16,6 +16,16 @@ class PlatoonProfilePolicyTest { assertTrue(PlatoonProfilePolicy.isValid(profile(101817u, "Owls"))) } + @Test + fun boundFlowCannotSwitchToAnotherOrUnregisteredPlatoon() { + assertFalse(PlatoonProfilePolicy.changesBoundIdentity(null, null)) + assertFalse(PlatoonProfilePolicy.changesBoundIdentity(null, "known")) + assertFalse(PlatoonProfilePolicy.changesBoundIdentity("known", "known")) + assertTrue(PlatoonProfilePolicy.changesBoundIdentity("known", "other")) + // An unregistered identity must not start admission while the flow stays bound. + assertTrue(PlatoonProfilePolicy.changesBoundIdentity("known", null)) + } + @Test fun admissionGateAllowsOneNewProfilePerClientAndAlwaysAllowsKnownProfiles() { val gate = PlatoonProfilePolicy.AdmissionGate() diff --git a/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt b/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt index 35c4e38..4714bb0 100644 --- a/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt @@ -23,16 +23,4 @@ class ScopedCaptureChecklistTest { assertTrue(checklist.targetComplete()) } - - @Test - fun clearRemovesTargetAndCapturedEvidence() { - val checklist = ScopedCaptureChecklist(setOf(1)) - checklist.mark("profile", 1, chooseTarget = true) - - checklist.clear() - - assertEquals(null, checklist.targetScopeId()) - assertTrue(checklist.targetCaptured().isEmpty()) - assertFalse(checklist.targetComplete()) - } } diff --git a/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt index 4b5675c..b1b5eab 100644 --- a/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt @@ -19,6 +19,8 @@ class DiscordWebhookPolicyTest { "https://discord.com:444/api/webhooks/12345678901234567/" + token, "https://discord.com/api/webhooks/12345678901234567/" + token + "?wait=true", "https://discord.com/api/users/12345678901234567/" + token, + "https://discord.com/api/webhooks/%3112345678901234567/" + token, + "https://discord.com/api/webhooks/12345678901234567/%61" + token, ).forEach { value -> assertTrue(runCatching { DiscordWebhookPolicy.requireValid(value) }.isFailure) } diff --git a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt index 2e1ea66..8bf4140 100644 --- a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt @@ -75,6 +75,44 @@ class CaptureHistoryStoreTest { } } + @Test + fun listingNeverDeletesAndOnlySavingRotates() { + val directory = Files.createTempDirectory("gf2log-history-list-test").toFile() + try { + repeat(CaptureHistoryStore.MAX_ENTRIES + 5) { index -> + directory.resolve("${index + 1}_11138_$index.txt").apply { + writeText("entry $index") + setLastModified((index + 1) * 1_000L) + } + } + val store = CaptureHistoryStore(directory) + + assertEquals(CaptureHistoryStore.MAX_ENTRIES + 5, store.list().size) + assertEquals(CaptureHistoryStore.MAX_ENTRIES + 5, store.list().size) + + store.save(payload(1)) + assertEquals(CaptureHistoryStore.MAX_ENTRIES, store.list().size) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun storesForTheSameDirectoryShareOneLock() { + val directory = Files.createTempDirectory("gf2log-history-lock-test").toFile() + try { + assertTrue( + HistoryDirectory(directory).lock === + HistoryDirectory(directory.resolve("nested").resolve("..")).lock, + ) + assertFalse( + HistoryDirectory(directory).lock === HistoryDirectory(directory.resolve("other")).lock, + ) + } finally { + directory.deleteRecursively() + } + } + private fun payload(index: Int): ParsedPayload = ParsedPayload( messageId = index, payloadType = 11138, diff --git a/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt index 0df9565..9270efc 100644 --- a/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test @@ -55,4 +56,28 @@ class BackupFormatPolicyTest { ) } } + + @Test + fun `auto-detected restore is complete only when the archive carries settings`() { + assertEquals( + RestoredBackupKind.COMPLETE, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, true, null), + ) + assertEquals( + RestoredBackupKind.PLATOON, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, false, null), + ) + assertEquals( + RestoredBackupKind.PLATOON, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.PLATOON_ONLY_VERSION, true, null), + ) + assertEquals( + RestoredBackupKind.COMPLETE, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, false, true), + ) + assertEquals( + RestoredBackupKind.PLATOON, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, true, false), + ) + } } diff --git a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt index d3d03ae..886cc31 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt @@ -162,12 +162,10 @@ class GunsmokeWeekSolverTest { assertEquals(MetricCertainty.EXACT, cells[4].meritCertainty) } - // Function Name: restoredSevenDayHistoryCompletesWithinTheNavigationBudget - // Description: - // - Reproduces the seven counter checkpoints that previously made week navigation hang. - // - Guards the complete solver path as well as the exact three-attempt daily projections. - // Returns: - // - Completes within three seconds with seven valid projected cells. + /** + * Reproduces the seven counter checkpoints that previously made week navigation hang; the + * complete solver path must finish within three seconds with exact three-attempt days. + */ @Test(timeout = 3_000) fun restoredSevenDayHistoryCompletesWithinTheNavigationBudget() { val totals = listOf( @@ -270,12 +268,11 @@ class GunsmokeWeekSolverTest { assertNull(contradictory.totals) } - // Function Name: finalEventPacketCanConfirmWeeklyAttemptsWithoutFixingTheirDailyPlacement - // Description: - // - Models three adjacent days whose two-attempt shortfall can move between days. - // - Proves the final event packet still fixes the whole-week total at twenty attempts. - // Returns: - // - Keeps the affected daily cells as lower bounds while publishing an exact aggregate. + /** + * Three adjacent days share a two-attempt shortfall whose placement can move. The final + * event packet still fixes the weekly total at twenty attempts while those days stay lower + * bounds. + */ @Test fun finalEventPacketCanConfirmWeeklyAttemptsWithoutFixingTheirDailyPlacement() { val days = (0L..6L).map(start::plusDays) @@ -513,7 +510,7 @@ class GunsmokeWeekSolverTest { } } - return GunsmokeWeekSolver.solve(UID, days, zone, snapshots, cells, facts) + return GunsmokeWeekSolver.resolve(UID, days, zone, snapshots, cells, facts).cells } private fun member( diff --git a/app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt new file mode 100644 index 0000000..eaa5846 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt @@ -0,0 +1,46 @@ +package dev.gf2log.app.management + +import dev.gf2log.protocol.GuildMembersCsv +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManualTextPolicyTest { + @Test + fun `member names keep the roster limit that the nickname field enforces`() { + assertEquals(GuildMembersCsv.MAX_MEMBER_NAME_CHARS, ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + val maximum = "n".repeat(ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + + assertEquals(maximum, ManualTextPolicy.memberName(" $maximum\t")) + assertThrows(IllegalArgumentException::class.java) { + ManualTextPolicy.memberName(maximum + "n") + } + assertThrows(IllegalArgumentException::class.java) { ManualTextPolicy.memberName(" \n ") } + } + + @Test + fun `notes are trimmed before the shared limit is checked`() { + val maximum = "x".repeat(ManualTextPolicy.MAX_NOTE_CHARS) + + assertEquals(maximum, ManualTextPolicy.note("\n$maximum ")) + assertEquals("", ManualTextPolicy.note(" ")) + assertThrows(IllegalArgumentException::class.java) { ManualTextPolicy.note(maximum + "x") } + } + + @Test + fun `the limit counts UTF-16 units like the Android input filters`() { + val emoji = "😀" + val atLimit = emoji.repeat(ManualTextPolicy.MAX_NOTE_CHARS / 2) + + assertEquals(atLimit, ManualTextPolicy.note(atLimit)) + assertThrows(IllegalArgumentException::class.java) { ManualTextPolicy.note(atLimit + emoji) } + } + + @Test + fun `a note at the limit fits the weekly history note budget`() { + // Weekly notes are stored in history up to MAX_NOTE_BYTES; one UTF-16 unit needs at most + // three UTF-8 bytes, so a stored weekly note is never shortened there. + assertTrue(ManualTextPolicy.MAX_NOTE_CHARS * 3 <= WeeklyReportHistoryCodec.MAX_NOTE_BYTES) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt index d0e4e1b..090faf5 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt @@ -51,6 +51,37 @@ class MembershipBoundaryValueTest { ) } + @Test + fun `an absent Updates boundary stays editable`() { + val requested = MembershipBoundaryValue( + date = LocalDate.of(2026, 7, 29), + instant = Instant.parse("2026-07-29T10:15:00Z"), + timeKnown = true, + ) + val period = MembershipPeriod( + id = 1, + uid = 7, + joinedAt = null, + leftAt = Instant.parse("2026-07-30T00:00:00Z"), + joinedDate = null, + leftDate = null, + joinedTimeKnown = true, + leftTimeKnown = true, + joinedPrecision = EvidencePrecision.UNKNOWN, + leftPrecision = EvidencePrecision.EXACT, + joinedSource = EvidenceSource.GAME_UPDATES, + leftSource = EvidenceSource.GAME_UPDATES, + note = "", + ) + + assertTrue(editableMembershipBoundaryChanged(null, requested, EvidenceSource.GAME_UPDATES)) + assertFalse(isImmutableMembershipBoundary(null, EvidenceSource.GAME_UPDATES)) + assertTrue(isImmutableMembershipBoundary(requested, EvidenceSource.GAME_UPDATES)) + assertTrue(period.isJoinedBoundaryEditable) + assertFalse(period.isLeftBoundaryEditable) + assertFalse(period.copy(joinedAt = requested.instant).isJoinedBoundaryEditable) + } + @Test fun allowsSameDateWhenEitherManualTimeIsUnknown() { val joined = boundary("2026-07-29", "2026-07-29T12:00:00Z", timeKnown = true) diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt index 079c385..b2fd434 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt @@ -2,28 +2,27 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate -import org.junit.Assert.assertFalse +import java.time.ZoneId +import java.time.ZoneOffset import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test class MembershipConsistencyPolicyTest { @Test - fun adjacentPeriodsAreValidAndTheOpenTailDefinesCurrentActivity() { + fun adjacentPeriodsAreValid() { val periods = listOf( interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), interval(2, "2026-02-01T00:00:00Z", null), ) - assertNull(MembershipConsistencyPolicy.violation(periods)) - assertTrue(MembershipConsistencyPolicy.isActive(periods)) + assertNull(violation(periods)) } @Test fun overlappingAndMultipleOpenPeriodsAreRejected() { assertNotNull( - MembershipConsistencyPolicy.violation( + violation( listOf( interval(1, "2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"), interval(2, "2026-02-01T00:00:00Z", "2026-04-01T00:00:00Z"), @@ -31,7 +30,7 @@ class MembershipConsistencyPolicyTest { ), ) assertNotNull( - MembershipConsistencyPolicy.violation( + violation( listOf( interval(1, "2026-01-01T00:00:00Z", null), interval(2, "2026-02-01T00:00:00Z", null), @@ -41,11 +40,10 @@ class MembershipConsistencyPolicyTest { } @Test - fun closedTimelineIsInactive() { + fun closedTimelineIsValid() { val periods = listOf(interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")) - assertNull(MembershipConsistencyPolicy.violation(periods)) - assertFalse(MembershipConsistencyPolicy.isActive(periods)) + assertNull(violation(periods)) } @Test @@ -55,8 +53,7 @@ class MembershipConsistencyPolicyTest { interval(2, "2026-02-01T00:00:00Z", null), ) - assertNull(MembershipConsistencyPolicy.violation(periods)) - assertTrue(MembershipConsistencyPolicy.isActive(periods)) + assertNull(violation(periods)) } @Test @@ -72,7 +69,7 @@ class MembershipConsistencyPolicyTest { leftTimeKnown = false, ) - assertNull(MembershipConsistencyPolicy.violation(listOf(period))) + assertNull(violation(listOf(period))) } @Test @@ -93,7 +90,7 @@ class MembershipConsistencyPolicyTest { joinedTimeKnown = false, ) - assertNull(MembershipConsistencyPolicy.violation(listOf(first, second))) + assertNull(violation(listOf(first, second))) } @Test @@ -114,7 +111,7 @@ class MembershipConsistencyPolicyTest { ) assertNull( - MembershipConsistencyPolicy.violation( + violation( listOf(laterInsertedFirst, earlierInsertedSecond), ), ) @@ -146,7 +143,7 @@ class MembershipConsistencyPolicyTest { leftDate = LocalDate.of(2026, 5, 4), ) - assertNull(MembershipConsistencyPolicy.violation(listOf(knownLate, dateOnly, knownEarly))) + assertNull(violation(listOf(knownLate, dateOnly, knownEarly))) } private fun interval(id: Long, joined: String?, left: String?) = MembershipInterval( @@ -154,4 +151,43 @@ class MembershipConsistencyPolicyTest { joinedAt = joined?.let(Instant::parse), leftAt = left?.let(Instant::parse), ) + + @Test + fun boundariesWithoutAStoredDateAreDatedInTheGameZone() { + val shanghai = ZoneId.of("Asia/Shanghai") + // A manual stay from 06:00 to 07:00 on 10-16, then an exact open join at 07:30 that day. + // In UTC the exact join falls on 10-15 and would sort, open, before the manual stay. + val manual = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-10-15T22:00:00Z"), + leftAt = Instant.parse("2026-10-15T23:00:00Z"), + joinedDate = LocalDate.of(2026, 10, 16), + leftDate = LocalDate.of(2026, 10, 16), + ) + val exact = MembershipInterval(id = 2, joinedAt = Instant.parse("2026-10-15T23:30:00Z"), leftAt = null) + + assertNull(MembershipConsistencyPolicy.violation(listOf(manual, exact), shanghai)) + } + + @Test + fun aDateOnlyRejoinOnTheDayOfAnExactWithdrawalIsNotAProvenOverlap() { + val shanghai = ZoneId.of("Asia/Shanghai") + val withdrawn = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-10-01T02:00:00Z"), + leftAt = Instant.parse("2026-10-16T02:00:00Z"), + ) + val rejoined = MembershipInterval( + id = 2, + joinedAt = Instant.parse("2026-10-15T16:00:00Z"), + leftAt = null, + joinedDate = LocalDate.of(2026, 10, 16), + joinedTimeKnown = false, + ) + + assertNull(MembershipConsistencyPolicy.violation(listOf(withdrawn, rejoined), shanghai)) + } + + private fun violation(periods: List) = + MembershipConsistencyPolicy.violation(periods, ZoneOffset.UTC) } diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt index d91f23c..3412af4 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt @@ -3,6 +3,7 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -46,6 +47,33 @@ class PlatoonMemberCsvTest { assertTrue(csv.contains("2026-01-01 09:00:00,2026-07-21 09:00:00")) } + @Test + fun exportsTheCurrentPeriodOfARejoinedMemberAndTheLatestPeriodOfAWithdrawnMember() { + val first = period(id = 1, joined = "2026-01-01T00:00:00Z", left = "2026-02-01T00:00:00Z") + val second = period(id = 2, joined = "2026-03-01T00:00:00Z", left = null) + val unknownStart = period(id = 3, joined = null, left = "2025-12-01T00:00:00Z") + val rejoined = status(uid = 10, active = true, periods = listOf(first, second, unknownStart)) + val withdrawn = status( + uid = 11, + active = false, + periods = listOf( + first, + period(id = 4, joined = "2026-03-01T00:00:00Z", left = "2026-04-01T00:00:00Z"), + unknownStart, + ), + ) + + val rows = PlatoonMemberCsv.format( + listOf(rejoined, withdrawn), + latestMembers = emptyMap(), + zoneId = ZoneId.of("UTC"), + ).lines() + + assertTrue(rows[1].endsWith(",2026-03-01 00:00:00,,")) + assertTrue(rows[2].endsWith(",2026-03-01 00:00:00,2026-04-01 00:00:00,")) + assertEquals(second, rejoined.latestMembershipPeriod()) + } + @Test fun exportsDateOnlyManualBoundariesWithoutInventingMidnightTimes() { val status = MemberStatus( @@ -107,4 +135,31 @@ class PlatoonMemberCsvTest { assertTrue(csv.contains("\"'=HYPERLINK(\"\"https://invalid\"\")\"")) assertTrue(csv.contains(",'+cmd")) } + + private fun status(uid: Long, active: Boolean, periods: List) = MemberStatus( + uid = uid, + name = "Member $uid", + level = 60, + isActive = active, + firstSeenAt = Instant.parse("2025-12-01T00:00:00Z"), + lastSeenAt = Instant.parse("2026-04-01T00:00:00Z"), + note = "", + membershipPeriods = periods, + ) + + private fun period(id: Long, joined: String?, left: String?) = MembershipPeriod( + id = id, + uid = 0, + joinedAt = joined?.let(Instant::parse), + leftAt = left?.let(Instant::parse), + joinedDate = null, + leftDate = null, + joinedTimeKnown = true, + leftTimeKnown = left?.let { true }, + joinedPrecision = EvidencePrecision.INFERRED, + leftPrecision = left?.let { EvidencePrecision.INFERRED }, + joinedSource = EvidenceSource.SNAPSHOT, + leftSource = left?.let { EvidenceSource.SNAPSHOT }, + note = "", + ) } diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt index 1784c4c..798fc57 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt @@ -6,8 +6,8 @@ import dev.gf2log.protocol.model.PlatoonUpdateEntry import dev.gf2log.protocol.model.PlatoonUpdateMember import dev.gf2log.protocol.model.PlatoonUpdatesData import java.time.Instant -import java.time.ZoneOffset import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class PlatoonObservationPolicyTest { @@ -133,16 +133,34 @@ class PlatoonObservationPolicyTest { } @Test - fun `weekly history work is capped after distinct week mapping`() { - val zone = ZoneOffset.UTC - val instants = generateSequence(Instant.parse("2020-01-06T05:00:00Z")) { - it.plusSeconds(7 * 24 * 60 * 60L) - }.take(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST + 50) - - val periods = WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) + fun `updates reject names and member lists that persisted rows cannot represent`() { + val valid = update(members = listOf(member(1))) + val oversizedName = update( + kind = PlatoonUpdateSemantics.KIND_REMOVED, + occurredAt = Instant.parse("2026-07-31T00:00:01Z"), + members = listOf( + member(2), + member(3).copy( + name = "x".repeat(PlatoonObservationPolicy.MAX_UPDATE_MEMBER_NAME_LENGTH + 1), + ), + ), + ) + val oversizedEntry = update( + occurredAt = Instant.parse("2026-07-31T00:00:02Z"), + members = (1L..PlatoonObservationPolicy.MAX_UPDATE_MEMBERS_PER_OBSERVATION + 1L) + .map(::member), + ) - assertEquals(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST, periods.size) - assertEquals(periods.size, periods.distinct().size) + assertEquals( + listOf(valid), + PlatoonObservationPolicy.updates(listOf(valid, oversizedName, oversizedEntry)), + ) + // Every accepted name stays encodable as a weekly-history member name (UTF-8 <= 3 bytes + // per UTF-16 unit). + assertTrue( + PlatoonObservationPolicy.MAX_UPDATE_MEMBER_NAME_LENGTH * 3 <= + WeeklyReportHistoryCodec.MAX_STRING_BYTES, + ) } private fun activity(name: String) = PlatoonActivityObservation( diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt index 7739311..2e2011c 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt @@ -23,6 +23,32 @@ class PlatoonPeriodsTest { ) } + @Test + fun gameDayFollowsTheLocalFiveAmResetAcrossDaylightSavingTransitions() { + val newYork = ZoneId.of("America/New_York") + // 2026-03-08 springs forward at 02:00 and 2026-11-01 falls back at 02:00. + listOf( + LocalDate.of(2026, 3, 7), + LocalDate.of(2026, 3, 8), + LocalDate.of(2026, 3, 9), + LocalDate.of(2026, 10, 31), + LocalDate.of(2026, 11, 1), + LocalDate.of(2026, 11, 2), + ).forEach { day -> + val reset = PlatoonPeriods.periodStartInstant(day, newYork) + assertEquals(day, PlatoonPeriods.gameDay(reset, newYork)) + assertEquals(day.minusDays(1), PlatoonPeriods.gameDay(reset.minusMillis(1), newYork)) + } + assertEquals( + LocalDate.of(2026, 3, 8), + PlatoonPeriods.gameDay(Instant.parse("2026-03-08T09:30:00Z"), newYork), + ) + assertEquals( + LocalDate.of(2026, 10, 31), + PlatoonPeriods.gameDay(Instant.parse("2026-11-01T09:30:00Z"), newYork), + ) + } + @Test fun everyWeeklyTableRunsFromSundayThroughSaturday() { val gunsmokeSunday = LocalDate.of(2026, 7, 19) diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt index 6b39b6d..ae5dee0 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt @@ -1,10 +1,12 @@ package dev.gf2log.app.management import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import java.io.ByteArrayOutputStream import java.io.DataOutputStream import java.time.Instant import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class PlatoonProfileRestoreJournalTest { @@ -37,6 +39,58 @@ class PlatoonProfileRestoreJournalTest { )) } + @Test + fun journalRoundTripsTheExactScopedTimeZoneKeys() { + val storageId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ) + listOf( + GameTimeZonePreferences.ScopedZoneState(region = null, zone = null), + GameTimeZonePreferences.ScopedZoneState(GameServerRegion.HAOPLAY_JAPAN.storedValue, null), + GameTimeZonePreferences.ScopedZoneState(GameServerRegion.MANUAL.storedValue, "Asia/Seoul"), + // Raw keys the timezone reader tolerates must roll back verbatim, not abort recovery. + GameTimeZonePreferences.ScopedZoneState("region_from_a_newer_app", "Not/AZone"), + null, + ).forEach { state -> + val value = PlatoonProfileRestoreJournal( + targetStorageId = storageId, + previousProfile = null, + previousActiveStorageId = null, + ownerPackage = null, + previousCaptureRegion = null, + previousTimeZone = state, + ) + + assertEquals(value, PlatoonProfileRestoreJournalCodec.decode( + PlatoonProfileRestoreJournalCodec.encode(value), + )) + } + } + + @Test + fun versionTwoJournalDecodesWithoutTimeZoneRollback() { + val storageId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ) + val bytes = ByteArrayOutputStream().use { target -> + DataOutputStream(target).use { output -> + output.writeInt(2) + output.writeUTF(storageId) + output.writeBoolean(false) + output.writeBoolean(false) + output.writeBoolean(false) + output.writeBoolean(false) + } + target.toByteArray() + } + + assertNull(PlatoonProfileRestoreJournalCodec.decode(bytes).previousTimeZone) + } + @Test fun legacyJournalRemainsReadableWithoutMisclassifyingOldListsAsBannerIds() { val storageId = PlatoonProfileIdentity.storageId( diff --git a/app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.kt new file mode 100644 index 0000000..0734501 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.kt @@ -0,0 +1,47 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences.ScopedZoneState +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RestoredTimeZonePolicyTest { + private val korea = GameServerRegion.HAOPLAY_KOREA + private val global = GameServerRegion.HAOPLAY_GLOBAL + private val automatic = ScopedZoneState(region = null, zone = null) + private val explicitKorea = ScopedZoneState(region = korea.storedValue, zone = null) + private val manual = ScopedZoneState(region = GameServerRegion.MANUAL.storedValue, zone = "Asia/Seoul") + + @Test + fun `an automatic region never needs clearing`() { + assertFalse(RestoredTimeZonePolicy.clearsOverride(automatic, korea, global, restoredSettings = false)) + assertFalse(RestoredTimeZonePolicy.clearsOverride(automatic, korea, korea, restoredSettings = true)) + } + + @Test + fun `a complete restore turns the archived profile region back into automatic`() { + assertTrue(RestoredTimeZonePolicy.clearsOverride(explicitKorea, null, korea, restoredSettings = true)) + assertTrue(RestoredTimeZonePolicy.clearsOverride(explicitKorea, global, korea, restoredSettings = true)) + } + + @Test + fun `a complete restore keeps a deliberately different or manual archived choice`() { + assertFalse(RestoredTimeZonePolicy.clearsOverride(explicitKorea, korea, global, restoredSettings = true)) + assertFalse(RestoredTimeZonePolicy.clearsOverride(manual, korea, korea, restoredSettings = true)) + } + + @Test + fun `a platoon-only restore clears every override when it changes the profile region`() { + assertTrue(RestoredTimeZonePolicy.clearsOverride(explicitKorea, korea, global, restoredSettings = false)) + assertTrue(RestoredTimeZonePolicy.clearsOverride(manual, korea, global, restoredSettings = false)) + // Keys left by an earlier scope with no registered profile cannot describe this one. + assertTrue(RestoredTimeZonePolicy.clearsOverride(manual, null, global, restoredSettings = false)) + } + + @Test + fun `a platoon-only restore leaves settings alone when the profile region is unchanged`() { + assertFalse(RestoredTimeZonePolicy.clearsOverride(explicitKorea, korea, korea, restoredSettings = false)) + assertFalse(RestoredTimeZonePolicy.clearsOverride(manual, global, global, restoredSettings = false)) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt new file mode 100644 index 0000000..3b14e3e --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt @@ -0,0 +1,149 @@ +package dev.gf2log.app.management + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Test + +class WeeklyHistoryWorkPolicyTest { + private val utc = ZoneOffset.UTC + private val closingWeek = LocalDate.of(2026, 7, 26) + private val openingWeek = LocalDate.of(2026, 8, 2) + private val laterWeek = LocalDate.of(2026, 8, 9) + private val listed = listOf(closingWeek, openingWeek, laterWeek) + + @Test + fun `weekly history work is capped after distinct week mapping`() { + val instants = generateSequence(Instant.parse("2020-01-06T05:00:00Z")) { + it.plusSeconds(7 * 24 * 60 * 60L) + }.take(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST + 50) + + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts(instants, utc, emptyList()) { null } + + assertEquals(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST, periods.size) + assertEquals(periods.size, periods.distinct().size) + } + + @Test + fun `neighbouring weeks never push the result past the per-ingest bound`() { + val weeks = (0L until WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST + 10L) + .map { openingWeek.plusWeeks(it) } + + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf(Instant.parse("2026-08-03T12:00:00Z")), + utc, + weeks, + ) { null } + + assertEquals(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST, periods.size) + assertEquals(openingWeek, periods.first()) + } + + @Test + fun `a mid-week instant before a later roster affects only its own week`() { + val periods = affected( + Instant.parse("2026-07-29T12:00:00Z"), + nextSnapshot = Instant.parse("2026-07-30T12:00:00Z"), + ) + + assertEquals(listOf(closingWeek), periods) + } + + @Test + fun `a roster at exactly the Sunday reset also affects the week it closes`() { + val periods = affected( + Instant.parse("2026-08-02T05:00:00Z"), + nextSnapshot = Instant.parse("2026-08-03T12:00:00Z"), + ) + + assertEquals(listOf(openingWeek, closingWeek), periods) + } + + @Test + fun `a roster just before the reset is the next week's baseline`() { + val periods = affected( + Instant.parse("2026-08-02T04:55:00Z"), + nextSnapshot = Instant.parse("2026-08-02T12:00:00Z"), + ) + + assertEquals(listOf(closingWeek, openingWeek), periods) + } + + @Test + fun `the newest roster carries into every later listed week`() { + val periods = affected(Instant.parse("2026-07-31T12:00:00Z"), nextSnapshot = null) + + assertEquals(listOf(closingWeek, openingWeek, laterWeek), periods) + } + + @Test + fun `carry-in stops at the first week opened after the next roster`() { + // A next roster at the opening reset itself is not yet before that week, so it still + // takes the earlier instant as its carried-in state. + assertEquals( + listOf(closingWeek, openingWeek), + affected( + Instant.parse("2026-07-31T12:00:00Z"), + nextSnapshot = Instant.parse("2026-08-02T05:00:00Z"), + ), + ) + assertEquals( + listOf(closingWeek), + affected( + Instant.parse("2026-07-31T12:00:00Z"), + nextSnapshot = Instant.parse("2026-08-02T04:59:59Z"), + ), + ) + } + + @Test + fun `an early calendar Sunday instant affects the next week's Join and Withdraw section`() { + val seoul = ZoneId.of("Asia/Seoul") + // 03:00 on Sunday 2026-08-02 in Seoul is still Saturday's game day. + val instant = Instant.parse("2026-08-01T18:00:00Z") + + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf(instant), + seoul, + listed, + ) { Instant.parse("2026-08-01T19:00:00Z") } + + assertEquals(listOf(closingWeek, openingWeek), periods) + } + + @Test + fun `neighbouring weeks are refreshed only when the history lists them`() { + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf(Instant.parse("2026-08-02T05:00:00Z")), + utc, + listOf(openingWeek), + ) { null } + + assertEquals(listOf(openingWeek), periods) + } + + @Test + fun `the next roster is looked up once after the latest instant of each week`() { + val lookups = mutableListOf() + + WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf( + Instant.parse("2026-07-29T12:00:00Z"), + Instant.parse("2026-07-31T12:00:00Z"), + Instant.parse("2026-07-30T12:00:00Z"), + ), + utc, + listed, + ) { instant -> + lookups += instant + null + } + + assertEquals(listOf(Instant.parse("2026-07-31T12:00:00Z")), lookups) + } + + private fun affected(instant: Instant, nextSnapshot: Instant?) = + WeeklyHistoryWorkPolicy.affectedPeriodStarts(sequenceOf(instant), utc, listed) { nextSnapshot } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt index 2fe3146..2195cbe 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import java.time.LocalDate import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -12,4 +13,15 @@ class WeeklyNotePolicyTest { assertFalse(WeeklyNotePolicy.canAdd(128)) assertFalse(WeeklyNotePolicy.canAdd(-1)) } + + @Test + fun acceptsOnlyDaysInsideTheirSundayBasedWeek() { + val sunday = LocalDate.of(2026, 8, 16) + + assertTrue(WeeklyNotePolicy.isWithinWeek(sunday, sunday)) + assertTrue(WeeklyNotePolicy.isWithinWeek(sunday, sunday.plusDays(6))) + assertFalse(WeeklyNotePolicy.isWithinWeek(sunday, sunday.minusDays(1))) + assertFalse(WeeklyNotePolicy.isWithinWeek(sunday, sunday.plusDays(7))) + assertFalse(WeeklyNotePolicy.isWithinWeek(sunday.plusDays(1), sunday.plusDays(1))) + } } diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt new file mode 100644 index 0000000..6930aa1 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt @@ -0,0 +1,145 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import dev.gf2log.app.management.WeeklyOverrideEditing.Field +import dev.gf2log.app.management.WeeklyOverrideEditing.Result +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyOverrideEditingTest { + private val week = LocalDate.of(2026, 10, 11) + private val day = week.plusDays(2) + + private fun stored( + merit: Long? = null, + score: Long? = null, + attempts: Int? = null, + attended: Boolean? = null, + patrol: Boolean? = null, + ) = WeeklyCellOverride(7, week, day, merit, score, attempts, attended, patrol) + + private fun valid(result: Result) = (result as Result.Valid).overrides + + @Test + fun untouchedStoredOverridesArePreserved() { + val existing = listOf(stored(merit = 90, attended = true)) + assertEquals(existing, valid(WeeklyOverrideEditing.merge(week, false, existing, emptyList()))) + } + + @Test + fun editedFieldsReplaceOnlyThemselves() { + val result = WeeklyOverrideEditing.merge( + week, + isGunsmokeWeek = true, + existing = listOf(stored(merit = 90, score = 100, attended = true)), + edits = listOf(CellEdit(7, day, score = "250", dailyPatrol = Choice.NO)), + ) + assertEquals(listOf(stored(merit = 90, score = 250, attended = true, patrol = false)), valid(result)) + } + + @Test + fun clearingEveryFieldRemovesTheOverride() { + val result = WeeklyOverrideEditing.merge( + week, + isGunsmokeWeek = false, + existing = listOf(stored(merit = 40, attended = true)), + edits = listOf(CellEdit(7, day, merit = " ", attended = Choice.UNSET)), + ) + assertTrue(valid(result).isEmpty()) + } + + @Test + fun standardWeeksNeverStoreGunsmokeFields() { + val result = WeeklyOverrideEditing.merge( + week, + isGunsmokeWeek = false, + existing = emptyList(), + edits = listOf(CellEdit(7, day, merit = "50", score = "300", attempts = "2")), + ) + assertEquals(listOf(stored(merit = 50)), valid(result)) + } + + @Test + fun invalidValuesIdentifyTheirField() { + assertEquals( + Result.Invalid(7, day, Field.ATTEMPTS), + WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day, attempts = "4"))), + ) + assertEquals( + Result.Invalid(7, day, Field.MERIT), + WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day, merit = "-1"))), + ) + } + + @Test + fun emptyEditsDoNotCreateOverrides() { + assertTrue(valid(WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day)))).isEmpty()) + } + + @Test + fun anEditedActivityChoiceResolvesAContradictionWithAStoredOne() { + // A completed Daily Patrol requires attendance, and storage rejects the contradiction. + assertEquals( + listOf(stored(attended = false, patrol = false)), + valid( + WeeklyOverrideEditing.merge( + week, + false, + listOf(stored(patrol = true)), + listOf(CellEdit(7, day, attended = Choice.NO)), + ), + ), + ) + assertEquals( + listOf(stored(attended = true, patrol = true)), + valid( + WeeklyOverrideEditing.merge( + week, + false, + listOf(stored(attended = false)), + listOf(CellEdit(7, day, dailyPatrol = Choice.YES)), + ), + ), + ) + } + + @Test + fun contradictoryActivityChoicesInOneEditAreInvalid() { + assertEquals( + Result.Invalid(7, day, Field.ACTIVITY), + WeeklyOverrideEditing.merge( + week, + false, + emptyList(), + listOf(CellEdit(7, day, attended = Choice.NO, dailyPatrol = Choice.YES)), + ), + ) + } + + @Test + fun everyValidMergeIsAcceptedByStorageRules() { + val choices = listOf(null) + Choice.entries + val storedValues = listOf(null, true, false) + storedValues.forEach { storedAttended -> + storedValues.forEach { storedPatrol -> + if (storedAttended == false && storedPatrol == true) return@forEach + choices.forEach { attended -> + choices.forEach { patrol -> + val result = WeeklyOverrideEditing.merge( + week, + false, + listOf(stored(merit = 1, attended = storedAttended, patrol = storedPatrol)), + listOf(CellEdit(7, day, attended = attended, dailyPatrol = patrol)), + ) + if (result is Result.Valid) { + assertTrue(result.overrides.none { it.attended == false && it.dailyPatrol == true }) + } + } + } + } + } + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt index 12bddd5..4cabf05 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt @@ -1,10 +1,14 @@ package dev.gf2log.app.management +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.DataInputStream import java.time.Instant import java.time.LocalDate +import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -135,4 +139,74 @@ class WeeklyReportHistoryCodecTest { WeeklyReportHistoryCodec.decode(payload) } + + @Test + fun roundTripKeepsManualOverridesOnTheirCells() { + val start = LocalDate.of(2026, 7, 26) + val merit = WeeklyCellOverride(42L, start, start.plusDays(1), 90L, null, null, true, null) + val patrol = WeeklyCellOverride(42L, start, start.plusDays(3), null, null, null, null, false) + val report = standardReport(start) { day -> + when (day) { + merit.gameDay -> merit + patrol.gameDay -> patrol + else -> null + } + } + + val decoded = WeeklyReportHistoryCodec.decode(WeeklyReportHistoryCodec.encode(report).payload) + + val cells = decoded.members.single().days + assertEquals(report.members.single().days.map { it.manualOverride }, cells.map { it.manualOverride }) + assertEquals(EvidencePrecision.MANUAL, cells[1].precision) + assertEquals(report.members.single().days.map { it.observed }, cells.map { it.observed }) + } + + @Test + fun aRevisionWithoutOverridesKeepsItsEarlierFormatAndFingerprint() { + val start = LocalDate.of(2026, 7, 26) + val encoded = WeeklyReportHistoryCodec.encode(standardReport(start) { null }) + + val header = DataInputStream(GZIPInputStream(ByteArrayInputStream(encoded.payload))).use { + it.readInt() + it.readInt() + } + + assertEquals(2, header) + assertNull( + WeeklyReportHistoryCodec.decode(encoded.payload).members.single().days + .firstNotNullOfOrNull { it.manualOverride }, + ) + } + + private fun standardReport( + start: LocalDate, + overrideOn: (LocalDate) -> WeeklyCellOverride?, + ): WeeklyReportBuilder.Report { + val days = List(7) { start.plusDays(it.toLong()) } + return WeeklyReportBuilder.Report( + periodStart = start, + periodEnd = start.plusDays(6), + isGunsmokeWeek = false, + days = days, + members = listOf( + WeeklyReportBuilder.MemberRow( + uid = 42L, + name = "Edited member", + days = days.map { day -> + val override = overrideOn(day) + WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = override?.meritDelta, + scoreDelta = null, + inference = null, + evidence = DailyEvidence.NO_OBSERVATION, + manualOverride = override, + ) + }, + totalMerit = 90L, + totalScore = 0L, + ), + ), + ) + } } diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt index 02247eb..415e843 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt @@ -1,6 +1,6 @@ package dev.gf2log.app.management -import dev.gf2log.app.WeeklyPngPendingState +import dev.gf2log.app.WeeklyPngExport import java.time.LocalDate import org.junit.Assert.assertEquals @@ -18,23 +18,23 @@ class WeeklyShareProjectionTest { @Test fun pendingWeeklyPngRestoresOnlyFromTheBoundedShareCache() { val cache = temporary.newFolder("cache") - WeeklyPngPendingState.directory(cache).mkdirs() + WeeklyPngExport.directory(cache).mkdirs() val periodStart = LocalDate.of(2026, 8, 9) - val published = WeeklyPngPendingState.newRenderTarget(cache, periodStart).apply { + val published = WeeklyPngExport.newRenderTarget(cache, periodStart).apply { writeBytes(byteArrayOf(1, 2, 3)) } - val nextRender = WeeklyPngPendingState.newRenderTarget(cache, periodStart) + val nextRender = WeeklyPngExport.newRenderTarget(cache, periodStart) - val savedName = WeeklyPngPendingState.nameForState(cache, published) + val savedName = WeeklyPngExport.nameForState(cache, published) assertNotEquals(published.name, nextRender.name) - assertEquals("GF2logger-week-20260809.png", WeeklyPngPendingState.exportName(periodStart)) - assertEquals(published.canonicalFile, WeeklyPngPendingState.restore(cache, savedName)) - assertEquals(null, WeeklyPngPendingState.restore(cache, "../outside.png")) - assertEquals(null, WeeklyPngPendingState.restore(cache, "GF2logger-week-20260809.png")) - assertEquals(null, WeeklyPngPendingState.restore(cache, nextRender.name)) + assertEquals("GF2logger-week-20260809.png", WeeklyPngExport.exportName(periodStart)) + assertEquals(published.canonicalFile, WeeklyPngExport.restore(cache, savedName)) + assertEquals(null, WeeklyPngExport.restore(cache, "../outside.png")) + assertEquals(null, WeeklyPngExport.restore(cache, "GF2logger-week-20260809.png")) + assertEquals(null, WeeklyPngExport.restore(cache, nextRender.name)) val outside = temporary.newFile(published.name) - assertEquals(null, WeeklyPngPendingState.nameForState(cache, outside)) + assertEquals(null, WeeklyPngExport.nameForState(cache, outside)) } @Test @@ -78,6 +78,26 @@ class WeeklyShareProjectionTest { assertEquals("Member 1", document.rows.single().member) } + @Test + fun shortenedLabelsAndNotesNeverSplitASurrogatePair() { + val emoji = "\uD83D\uDE00" + // An even run of ASCII leaves the 128th and 512th UTF-16 unit on an emoji's high half. + val name = "a".repeat(127) + emoji + "tail" + val note = "b".repeat(511) + emoji + "tail" + val base = report() + val report = base.copy(members = base.members.map { it.copy(name = name) }) + + val row = WeeklyShareProjection.build( + report, + report.members, + privateNotesByUid = mapOf(123L to note), + privacy = WeeklyShareProjection.Privacy(includePrivateNotes = true), + ).rows.single() + + assertEquals("a".repeat(127), row.member) + assertEquals("b".repeat(511), row.privateNote) + } + private fun report(): WeeklyReportBuilder.Report { val start = LocalDate.of(2026, 8, 10) val days = (0L..6L).map(start::plusDays) diff --git a/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt b/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt new file mode 100644 index 0000000..aced3a3 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt @@ -0,0 +1,70 @@ +package dev.gf2log.app.settings + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WeeklyCutlineFieldTest { + @Test + fun persistedKeySpellingsNeverChange() { + assertEquals( + listOf( + "cutline.dailyMerit", + "cutline.dailyGunsmokeScore", + "cutline.dailyGunsmokeAttempts", + "cutline.weeklyMerit", + "cutline.weeklyGunsmokeScore", + "cutline.weeklyGunsmokeAttempts", + "cutline.weeklyLoginDays", + "cutline.weeklyPatrolDays", + ), + WeeklyCutlineField.entries.map(WeeklyCutlineField::backupKey), + ) + assertEquals( + listOf( + "cutline_daily_merit", + "cutline_daily_gunsmoke_score", + "cutline_daily_gunsmoke_attempts", + "cutline_weekly_merit", + "cutline_weekly_gunsmoke_score", + "cutline_weekly_gunsmoke_attempts", + "cutline_weekly_login_days", + "cutline_weekly_patrol_days", + ), + WeeklyCutlineField.entries.map(WeeklyCutlineField::settingsKey), + ) + assertEquals( + listOf( + "daily_merit", + "daily_score", + "daily_attempts", + "weekly_merit", + "weekly_score", + "weekly_attempts", + "weekly_login", + "weekly_patrol", + ), + WeeklyCutlineField.entries.map(WeeklyCutlineField::scopedKey), + ) + assertEquals( + listOf(false, false, true, false, false, true, true, true), + WeeklyCutlineField.entries.map(WeeklyCutlineField::storedAsInt), + ) + } + + @Test + fun everyFieldRoundTripsThroughTheTable() { + val cutlines = WeeklyCutlines( + dailyMerit = 90, + dailyGunsmokeScore = 10_000, + dailyGunsmokeAttempts = 3, + weeklyMerit = 630, + weeklyGunsmokeScore = 70_000, + weeklyGunsmokeAttempts = 21, + weeklyLoginDays = 7, + weeklyPatrolDays = 5, + ) + + assertEquals(cutlines, WeeklyCutlineField.cutlinesOf { it.valueIn(cutlines) }) + assertEquals(WeeklyCutlines(), WeeklyCutlineField.cutlinesOf { null }) + } +} diff --git a/build.gradle b/build.gradle index 6df953a..bbe300b 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20-Beta1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21' } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8e08656..6777f25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -110,9 +110,19 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. fragments. The first continuation is checked before retention; exceeding either limit discards the pending payload and quarantines its remaining fragments through the logical terminal frame before later datasets are parsed. + A malformed or truncated continuation frame discards its dataset the same way. Quarantine is advanced from frame metadata before protobuf decoding, so even a malformed terminal fragment clears the discarded dataset deterministically. -- The parser service has one worker and a queue capped at 256 payload chunks. +- One serial `CaptureWorker` owns every parser, flow, session, and admission + structure, so that state needs no locks. Native callbacks share a budget of + 256 queued chunks; lifecycle, confirm, and discard tasks bypass that budget + and are never rejected, so a pending Platoon can be confirmed or discarded + after capture stops. `CaptureFlowEngine` routes payloads, + `PlatoonAdmissionCoordinator` owns pending admission, and the service is + lifecycle glue whose fields are main-thread only. +- Stopping capture never blocks the main thread: the worker stops the native + loop, drains already queued chunks, and clears per-run state; a start that + arrives during teardown waits for it to finish. - Before identity is established, each flow retains at most 32 decoded payload objects. Overflow rejects that flow until closure. The profile registry holds at most 16 bounded identities. @@ -124,6 +134,9 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. capture may admit at most one new profile per supported client. Explicit profile deletion removes one immutable scope behind two confirmations. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. + A TCP flow is classified once, on its first payload in either direction, so + a later plaintext segment that happens to begin with TLS-like bytes can never + disable inspection. - Outgoing plaintext chunks are used only for native flow classification. - Flow-close callbacks finalize any pending recognized payload before removing parser state and unconditionally remove metadata even when no parser existed. A rejected close callback @@ -153,7 +166,7 @@ preserving in-flow ordering. 1. Start forwarding using the supplied TUN file descriptor. 2. Stop and release native resources. -The listener reports candidate plaintext payloads after TCP reassembly, flow closure, unexpected native termination, and aggregate byte counters. Every upstream socket is passed through `VpnService.protect()` so forwarded traffic cannot re-enter the VPN. +The listener reports candidate plaintext payloads after TCP reassembly, flow closure, unexpected native termination, and aggregate byte counters. Flow ids are unique for the life of the process, so a pending Platoon that outlives one capture can never refer to a flow of the next. Every upstream socket is passed through `VpnService.protect()` so forwarded traffic cannot re-enter the VPN. The implementation uses zdtun rather than introducing a new TCP/IP stack. General PCAP export, nDPI classification, TLS decryption, root capture, remote @@ -199,7 +212,11 @@ payload with 128-character names, and Updates ingestion accepts at most 250 distinct supported observations. Packet-triggered weekly-history work derives only from those accepted observations and is independently capped at 32 distinct periods per ingest, so rejected timestamps cannot amplify report or -SQLite work. SQLite retains the newest 10,000 activity +SQLite work. Within that cap, `WeeklyHistoryWorkPolicy` adds the listed +neighbouring weeks that read an instant across a 05:00 reset (the closed week +of an exact-reset roster, the calendar-day Join / Withdraw week, and later +weeks up to the next stored roster), so their stored revisions never lag the +live projection. SQLite retains the newest 10,000 activity facts, and one ingestion resolves at most 250 unresolved facts. A database-local cursor rotates those bounded batches through the complete retained backlog. Schema v11 owns the rotation cursor and a global `(captured_at, id)` retention @@ -207,7 +224,11 @@ index. Schema v13 repairs the derived active-member projection from canonical membership periods during upgrade; supported older backups are upgraded before strict current-schema validation. These limits are enforced again at repository and persistence boundaries so a -future caller cannot bypass the protocol-layer checks. +future caller cannot bypass the protocol-layer checks. User-entered text has +the same treatment: `ManualTextPolicy` trims and then rejects a member name +over 256 characters or a private, membership-period, or weekly note over +1,000 characters inside `MembershipEditing` and `WeeklyAnnotationStore`, +before any row is written. ## HTTPS and application-layer encryption @@ -233,29 +254,36 @@ plaintext TCP streams. Do not add pinning or anti-cheat bypasses. ## Display and first-launch boundary -`LocalizedActivity` applies both locale and System, Light, or Dark appearance -through a configuration-wrapped base context before an Activity creates its -views. `ModernUi` supplies shared presentation primitives only; it does not own -capture, parsing, persistence, or report policy. Theme-specific resources keep -packet tables, weekly grids, controls, and status surfaces readable without -duplicating domain logic in Activities. - -`PrimaryNavigation` is the presentation-only shell shared by Home, Platoon, and -Weekly. It launches existing Activities and owns no data or domain state. -Onboarding overrides only the resolved presentation theme so the approved dark +`BaseActivity` extends AndroidX `ComponentActivity`. It applies the persisted +language and System, Light, or Dark appearance through a configuration-wrapped +base context before views exist, draws edge to edge, and observes the pending +Platoon admission store so a newly detected Platoon is offered for confirmation +as soon as it appears. Screens are composed from the presentation-only +`dev.gf2log.app.ui` package (`Screen`, `TopBar`, `Controls`, `Views`, +`StateViews`, `Dialogs`); it owns no capture, parsing, persistence, or report +policy. `BackgroundWork` runs each screen's repository, file, and network calls +in submission order off the main thread and delivers results only while the +screen exists, with keyed requests superseding stale loads. + +Capture state reaches the UI as an immutable `CaptureSnapshot` (phase, latest +typed and localized notice, captured targets, one-time checklist) published by +`CaptureStatus` listeners on the main looper. Nothing in the UI parses status +text or polls. + +`PrimaryNavigation` provides the Home, Platoon, and Weekly bar used by `Screen`. +It launches existing Activities and owns no data or domain state. Onboarding +overrides only the resolved presentation theme so the approved dark walkthrough remains stable; its selected language still persists through the -same user-settings store as the rest of the app. +same user-settings store as the rest of the app, and Settings can replay it +without resetting the first-launch flag. The concrete presentation roles, warm light/dark palette, compact touch-target contract, and accessibility review gates are documented in -[`UI_DESIGN_SYSTEM.md`](UI_DESIGN_SYSTEM.md). Activities select semantic roles; -they do not define one-off button palettes or treat compound controls as generic -buttons. +[`UI_DESIGN_SYSTEM.md`](UI_DESIGN_SYSTEM.md). `MainActivity` routes an installation with no completed first-use flag to the non-exported `OnboardingActivity`. The five-page guide may be finished or -skipped, and writes completion only at that explicit exit. Its English/Korean -segmented control persists the same language preference used by Settings. +skipped, and writes completion only at that explicit exit. Complete backup settings schema v4 includes language, theme, onboarding completion, and the server-region reset preset. Historical manual timezone values remain decodable for backup compatibility but are no longer offered in @@ -273,8 +301,13 @@ from two clients cannot be combined into a false completion. CSV preview and apply retain the same immutable scope even if an Activity is recreated. A scoped backup is validated before profile metadata changes. Its database, settings, retained CSV retirement, registry metadata, active profile, -and client-region routing then commit under one durable journal; interrupted -restores roll all of those resources back together. +client-region routing, and the scoped reset-zone keys then commit under one +durable journal; interrupted restores roll all of those resources back +together. The journal keeps the exact previous timezone keys because replaying +effective settings would turn an automatic region into an explicit one. +`RestoredTimeZonePolicy` decides, after the profile is installed, whether the +restored override is redundant (complete restore) or stale (Platoon-only +restore that changed the server region) and clears it. Profile server edits retain the immutable scope and rebuild weekly history under the repository's exclusive database gate. Full profile deletion uses a durable @@ -289,12 +322,19 @@ the native listener contract); encapsulation is provided by stores and the repository facade. Android lifecycle inheritance remains shallow through `LocalizedActivity`, `VpnService`, and `SQLiteOpenHelper`. -The large SQLite helper remains a maintenance boundary. Schema, migrations, -and transactional evidence correlation stay together to preserve atomic -ingestion. Read-only roster snapshot projection is extracted into -`WeeklySnapshotStore` behind the repository-facing database API; further -extractions must preserve the same single-transaction write owner and must not -let Activities issue SQL directly. +`PlatoonDatabase` is the single SQLite helper and transactional write owner of +one scope. Its SQL lives in package-internal objects that receive the open +database (`PlatoonDatabaseSchema`, `PlatoonDatabaseMigrations`, +`SnapshotIngestion`, `RosterHistoryReplay`, `ActivityIngestion`, +`ExactBoundaryCorrelation`, `MembershipEditing`, `MembershipRows`, +`PlatoonReadQueries`, `WeeklyAnnotationStore`, `WeeklyHistoryStore`, and +`WeeklySnapshotStore`), so one ingest still commits or rolls back as a whole. +Activities never issue SQL directly. + +Locks are always acquired in one order: a CSV checkpoint's per-scope claim, +the repository maintenance lock, the database helper, the profile registry, +then settings stores. Restore recovery runs only when durable restore artifacts +exist, so constructing a repository never takes the exclusive gate. ## Parsed-packet history @@ -308,7 +348,12 @@ atomically copies selected entries into the same profile's `saved-history` directory, rejects duplicates, caps the collection at 50 without FIFO deletion, and keeps saved entries independent from recent-history rotation. -The main activity renders both collections with timestamp-only titles in `yy/MM/dd HH:mm:ss` using the Android device timezone. Selecting a title opens a cleaned table parsed from the stored CSV body; the same screen can reveal the complete raw stored text and copy it to the clipboard. Android backup rules exclude all private files, including recent history, saved history, generated Platoon CSV files, and the structured management database. A user can separately invoke the explicit Platoon backup export, which contains parsed management data but never raw traffic. +Every store instance for the same directory shares one process-wide lock, and +only saving trims the recent collection; listing never deletes. Home renders +both collections as Recent and Saved tabs with timestamp-only titles in +`yy/MM/dd HH:mm:ss` using the Android device timezone. Selecting a title opens a +cleaned table parsed from the stored CSV body; the same screen switches to the +complete raw stored text and back, and can copy it to the clipboard. Android backup rules exclude all private files, including recent history, saved history, generated Platoon CSV files, and the structured management database. A user can separately invoke the explicit Platoon backup export, which contains parsed management data but never raw traffic. ## Explicit backup boundary @@ -331,7 +376,10 @@ the repository maintenance lock. The previous database and a settings snapshot are retained until both resources commit, so any failure restores the prior state instead of leaving a partial import. A fresh install records that no database existed so rollback removes the staged replacement rather than -mistaking it for prior state. Schema introspection uses the extended SQLite +mistaking it for prior state. Cleanup deletes the transaction state file +first, so a process death during cleanup leaves only leftovers to delete +rather than a half-cleaned PREPARED transaction that recovery would reject on +every start. Schema introspection uses the extended SQLite column metadata where available and the equivalent ordinary-column metadata on the older SQLite engine shipped with the supported Android 8 minimum. @@ -386,11 +434,17 @@ packet, import, or manual override that changes the live fingerprint clears that selection. The history Activities can list, preview, and select revisions, but cannot mutate or delete individual history rows. +Manual weekly corrections are merged with stored overrides by +`WeeklyOverrideEditing`: only edited fields change, untouched fields keep their +stored override, and the complete override set for the week is replaced as one +unit. + `WeeklyReportActivity` loads one immutable projection on a serialized worker. A lifecycle-independent `WeeklyReportStateHolder` owns the selected date and generation token, rejects results superseded by navigation or lifecycle -changes, and survives Activity recreation. A bounded `RecyclerView` recycles -member rows instead of accumulating the entire nested table hierarchy. The +changes, and survives Activity recreation. One vertical `RecyclerView` hosts the +week navigator, a sticky day header, recycled member rows with a frozen member +column and synced horizontal scrolling, and the membership and note sections. The Gunsmoke solver merges equivalent partial histories instead of retaining every complete path and enforces deterministic state/operation budgets; evidence beyond those budgets stays conservative rather than blocking diff --git a/docs/PLATOON_CAPTURE.md b/docs/PLATOON_CAPTURE.md index b8f4e49..74eff76 100644 --- a/docs/PLATOON_CAPTURE.md +++ b/docs/PLATOON_CAPTURE.md @@ -83,7 +83,7 @@ preferred source for Join/Withdraw history and Daily Patrol facts. Type `21917` remains the authoritative current roster, and type `21935` remains incremental supporting activity evidence. -The reference may receive one logical dataset across multiple inner payloads. It continues when the payload type matches and the previous outer message id is `0`, or when both outer message ids match. mobileGF2logger preserves that batching rule when writing CSV files. +The reference may receive one logical dataset across multiple inner payloads. It continues when the payload type matches and the previous outer message id is `0`, or when both outer message ids match. mobileGF2logger applies that batching rule once, in the stream parser. A dataset cut short by a malformed frame, an unrelated payload, or flow closure is discarded whole and never merged into a later roster, so a partial roster can never mark the missing members as withdrawn. The reference reports Platoon data during login, sometimes twice during login, after reconnection, and on Platoon pages. The 21935 and 21960 responses are diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 2485950..c2d8d80 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -41,8 +41,16 @@ rules below take precedence over stale copied dates in archived templates. remain in a bounded in-memory candidate until the user selects one of the six supported regions compatible with the VPN-owner-verified client. Only then is its immutable scoped repository created and the buffered evidence ingested. + When Settings holds an explicit capture server for that client, the dialog + preselects it; the user still confirms before anything is stored. - Changing a profile's server rebuilds its weekly history without changing its - storage scope. Deleting a profile removes only that scope after two warnings + storage scope. A restore keeps the profile's server and its reset region + consistent: a complete backup stores only the effective reset region, so a + region equal to the archived profile's server is restored as automatic rather + than as an explicit override, and a Platoon-only restore that changes the + profile's server clears any reset-region or manual-zone override, as a server + change in Settings does. An interrupted restore puts the exact previous + timezone keys back. Deleting a profile removes only that scope after two warnings and an exact Platoon-name confirmation; interrupted cleanup resumes later. ## Merit calculation @@ -144,6 +152,13 @@ transitions. - UID is the stable member identity; a display name can change over time. - A custom nickname is editable and immediately becomes the display name in Platoon management; the personal note remains private to member details. +- Manual text is bounded where it is stored, not only by input fields. After + trimming, a custom nickname or manually added member name holds at most 256 + characters (the roster name limit), and a private member note, membership-period + note, or weekly note holds at most 1,000 characters (the weekly-note field + limit). Longer text is rejected and nothing is written, so the draft is never + silently cut. Weekly history may still shorten a private note to its 2 KiB + per-member budget; a 1,000-character weekly note always fits its 16 KiB one. - The first observed roster creates an active membership period with an unknown historical join time unless the user supplies one. - A UID added between consecutive snapshots opens a join or rejoin membership period. @@ -154,9 +169,14 @@ transitions. - Manual edits are accepted only when every period ends after it starts, no two fully dated periods overlap, and at most one period remains open. An absent snapshot-derived start remains unknown rather than being treated as an - infinitely old boundary. Current active state is - derived from that open period inside the same transaction; schema-v13 upgrade - repairs any stale derived flag left by an older installation. + infinitely old boundary. Snapshot and exact boundaries are dated in the + Platoon's game timezone for this check, the same calendar manual dates use, + so a manual same-day boundary with an unknown time is never a proven overlap + with a timed one. Every writer derives current active state from the + periods inside the same transaction with one rule: a member is active while an + open period remains effective. An open period started by an exact Updates join + is superseded by a later recorded exit or by a newer roster that omits the + member. Schema-v13 upgrade repairs any stale derived flag with the same rule. - A roster CSV imported after newer captures still contributes historical presence spans. The replay creates missing inactive members and inferred withdrawal/rejoin periods without replacing manual or exact Updates evidence. @@ -184,15 +204,42 @@ transitions. - Each stored history item is a complete immutable weekly-table revision. The report cells, displayed membership events, weekly notes, names, and private notes are encoded together rather than combining an older table with current - member data. + member data. Each cell's manual override is encoded with it (history format + 3), so a restored revision still shows which cells were edited and by how + much; a revision without overrides keeps format 2 and its fingerprint. - Every revision passes deterministic structural checks before persistence and after decoding: seven contiguous days, unique member UIDs, matching cell dates and report mode, in-period notes, and valid contextual identities. - Changing the configured game timezone clears incompatible derived history and rebuilds it from the unchanged UTC evidence using the new 05:00 boundary. +- Captured evidence refreshes every listed week that reads it, not only the + week of its game day. A roster captured exactly at Sunday 05:00 is the + closing Saturday capture of the week it ends, so that week is re-recorded + too. A current roster, or a membership event before the next roster, is the + carried-in baseline and period-end roster of every later listed week until + the next stored roster, so those weeks are re-recorded as well; this covers a + roster captured just before the reset. An instant before 05:00 on a calendar + Sunday also refreshes the following week, whose Join / Withdraw section uses + calendar days. A roster that joins or withdraws members also refreshes the + weeks of the exact boundary times it correlates from earlier activity, which + can precede the roster's own week. The 32-period per-ingest bound still + applies, own weeks first. - Weekly history is a derived audit trail, not the primary store. If refreshing one revision fails after a successful data mutation, the affected restored selection is cleared so the live database projection remains authoritative. +- A manual Daily Patrol requires manual attendance. When an edit sets one of + the two and the other stored choice contradicts it, the edited choice wins + (Patrol yes also sets Login yes; Login no also sets Patrol no); Login no with + Patrol yes in the same edit is rejected before saving, so no edit can produce + a pair that storage refuses. +- Weekly notes and manual overrides must fall inside their own week; anything + else is rejected before it can make a revision fail its structural checks. +- A captured roster older than the latest stored one, from a late packet or a + clock moved backwards, is replayed into history instead of being treated as + the current roster, so it never withdraws members of a newer roster. +- A consumed CSV import checkpoint is retired by an atomic rename to + `csv-import-checkpoint.discarded` before deletion, so a crash mid-cleanup can + never block later imports or undo. ## Persistence and privacy @@ -209,7 +256,7 @@ The same neutralization is applied to captured roster, activity, Updates, and formation packet tables before they can be copied or opened in spreadsheet software. -Untrusted evidence is bounded at every persistence boundary. A roster import +Untrusted evidence is bounded at every persistence boundary. A 21960 Updates entry is dropped whole when any name exceeds 256 characters or it lists more than 256 members. A roster import must contain no more than 256 unique UIDs, an activity payload contributes at most 250 distinct observations, and the database keeps the newest 10,000 activity facts. If a newly selected roster CSV fails parsing, duplicate-UID diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md index 44a9c36..bd3070a 100644 --- a/docs/UI_DESIGN_SYSTEM.md +++ b/docs/UI_DESIGN_SYSTEM.md @@ -20,33 +20,58 @@ repository or policy APIs, while `ModernUi` owns reusable presentation roles. - Bold type is limited to screen, section, and item headings. Body copy uses the platform sans-serif; table-like data may use monospaced figures. +## Building blocks + +Screens are composed in code from the shared `dev.gf2log.app.ui` package. An +Activity chooses components and roles; it never defines colors, dp math, or +one-off drawables. + +| Component | Use | +| --- | --- | +| `Screen` | Edge-to-edge shell: top bar, replaceable body, optional Home/Platoon/Weekly bar. Applies status, navigation, cutout, and keyboard insets once. | +| `TopBar` | 56dp bar with optional back/close navigation, title and subtitle, up to two icon actions, and an overflow menu for secondary utilities. | +| `Controls` | Button roles, checkbox/switch tints, and the segmented rail. | +| `Views` | Text roles, labeled fields, grouped navigation rows, switch rows, panels, badges. | +| `StateViews` | One loading, empty, and failure composition for every screen. | +| `Dialogs` / `Feedback` | Styled dialogs with a red treatment for destructive confirmations; a single replacing toast. | +| `BackgroundWork` | Serialized off-main work bound to the screen lifecycle; keyed requests supersede stale ones. | +| `SingleViewAdapter` | Hosts headers, footers, and state views inside one `RecyclerView` without recycling them. | + ## Control roles -`ModernUi.ControlRole` is the source of truth for action hierarchy: +`Controls.Role` is the source of truth for action hierarchy: | Role | Use | | --- | --- | -| Primary | The single commitment action for the current screen | -| Capture | Start or prepare continuous capture; green state treatment | -| Feature | Key navigation to Platoon management or the weekly table; soft orange surface | -| Evidence | Compact date/time action in a packet-history row | -| Secondary | Ordinary bounded action on the current screen | -| Tertiary | Low-emphasis action such as Back, Skip, or a week arrow | -| Navigation | Neutral full-row navigation or selection | -| Selector | Compact active-Platoon selector with a persistent outline and accent pressed state | -| Destructive | Confirmed destructive operation | -| Destructive text | Less prominent destructive option separated from primary work | - -Buttons keep a 48dp touch target but use an inset 40dp visible surface. A -multi-line destination uses `ModernUi.actionRow`: a 56dp row with a title, -optional detail, and chevron instead of a tall button containing paragraphs. -Do not encode `CheckBox` or `RadioButton` as generic buttons. - -Language and theme selectors are a single 56dp rail, not a row of adjacent -buttons. The rail has a 16dp radius and 4dp padding on all four sides. One 48dp, -12dp-radius accent thumb moves between equal-width native radio choices. This -concentric geometry keeps the horizontal and vertical inset identical and -prevents selected and unselected options from developing mismatched seams. +| Primary | The single commitment action of a screen or panel | +| Capture | Start continuous capture; green readiness treatment | +| Secondary | Ordinary bounded action | +| Tertiary | Low-emphasis inline action such as Select, Show all, or This week | +| Destructive | Confirmed destructive operation, such as Stop | +| Destructive text | Less prominent destructive option inside a selection bar | + +Buttons keep a 48dp touch target around a 40dp visible surface. Multi-line +destinations use grouped navigation rows with a title, supporting line, and +chevron instead of tall buttons. Secondary utilities live in the top-bar +overflow menu rather than as stacked full-width buttons. + +Choices use one 56dp rail with a 16dp radius and 4dp inset; a 48dp, 12dp-radius +accent thumb moves between equal-width native radio buttons. Motion is skipped +when Android animations are disabled. + +## Interaction patterns + +- Lists enter selection mode on long-press or from the overflow menu. The top + bar becomes a contextual bar (close, count, actions) and Back exits selection. +- Edit modes (the weekly table) replace the top bar with close and save actions, + highlight changed cells, and confirm before discarding unsaved changes. +- Every repository, file, and network call runs through `BackgroundWork`; the + screen shows a loading, empty, or failure state instead of blocking input. +- Text fields carry stable view IDs and Activities save transient choices, so + rotation, theme or language changes, and large-screen resizing keep drafts. +- Document pickers use the Activity Result API. Exports regenerate their content + from the repository when the destination returns rather than holding it in + memory across process death. ## Layout and accessibility @@ -66,9 +91,13 @@ prevents selected and unselected options from developing mismatched seams. bottom navigation bar. Settings remains a secondary screen with a normal back action; deeper detail and edit screens continue to use Android Back. - Compact controls may look smaller, but their hit area remains at least 48dp. +- Content draws edge to edge; system-bar and keyboard insets are applied by + `Screen`, so no screen hides content under the status or navigation bar. - Text wraps when it carries meaning; only short button labels may ellipsize. -- Icon-only controls require an accessible name. Decorative icons beside text - are hidden from the accessibility tree. +- Icon-only controls require an accessible name and show it as a tooltip. + Decorative icons beside text are hidden from the accessibility tree. +- Composite rows expose one button with a combined description; validation + errors and live status use polite accessibility live regions. - Pressed and disabled states are explicit. Destructive operations use a confirmation dialog, and async work disables duplicate submission. - Motion is limited to short transform/opacity transitions and is skipped when @@ -81,7 +110,8 @@ prevents selected and unselected options from developing mismatched seams. Before adding or changing a screen: 1. Identify the one primary action and assign every other control a lower role. -2. Use semantic resource colors; do not add raw RGB values in Activities. +2. Use `ui` components and semantic resource colors; do not add raw RGB values, + `GradientDrawable`s, or dp math in Activities. 3. Verify English and Korean labels at the supported system font scales. 4. Check a dark and light physical-device render, including system bar contrast. 5. Confirm that presentation code does not acquire SQL, parser, inference, or diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt index 0003fa8..8260c70 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt @@ -1,6 +1,8 @@ package dev.gf2log.protocol import dev.gf2log.protocol.internal.ProtoReader +import dev.gf2log.protocol.internal.collectMessages +import dev.gf2log.protocol.internal.forEachField import dev.gf2log.protocol.model.Doll import dev.gf2log.protocol.model.Formation import dev.gf2log.protocol.model.FormationsData @@ -43,6 +45,20 @@ object Gfl2PayloadDecoder { TYPE_FORMATIONS, ) + /** Allocation-free equivalent of `type in supportedTypes` for the per-frame hot path. */ + fun isSupported(type: Int): Boolean = when (type) { + TYPE_WEAPONS, + TYPE_WEAPON_MODS, + TYPE_PUBLIC_SKILL_ITEMS, + TYPE_PLATOON_PROFILE, + TYPE_GUILD_MEMBERS, + TYPE_PLATOON_ACTIVITY, + TYPE_PLATOON_UPDATES, + TYPE_FORMATIONS, + -> true + else -> false + } + @Throws(ProtocolException::class) fun decode(type: Int, bytes: ByteArray): GameData? = when (type) { TYPE_WEAPONS -> decodeWeapons(ProtoReader(bytes)) @@ -56,14 +72,8 @@ object Gfl2PayloadDecoder { else -> null } - private fun decodeWeapons(reader: ProtoReader): WeaponsData { - val weapons = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) weapons += decodeWeapon(reader.readMessage(field)) else reader.skip(field) - } - return WeaponsData(weapons) - } + private fun decodeWeapons(reader: ProtoReader) = + WeaponsData(reader.collectMessages(1, ::decodeWeapon)) private fun decodeWeapon(reader: ProtoReader): Weapon { var id = 0u @@ -74,8 +84,7 @@ object Gfl2PayloadDecoder { var breakTimes = 0u var rawFlags = 0u val weaponMods = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field).toUInt() 2 -> stcId = reader.readUInt(field).toUInt() @@ -94,8 +103,7 @@ object Gfl2PayloadDecoder { private fun decodeWeaponModBinding(reader: ProtoReader): WeaponModBinding { var id = 0u var gunId = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field).toUInt() 2 -> gunId = reader.readUInt(field).toUInt() @@ -105,18 +113,8 @@ object Gfl2PayloadDecoder { return WeaponModBinding(id, gunId) } - private fun decodeWeaponMods(reader: ProtoReader): WeaponModsData { - val mods = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - mods += decodeWeaponMod(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return WeaponModsData(mods) - } + private fun decodeWeaponMods(reader: ProtoReader) = + WeaponModsData(reader.collectMessages(1, ::decodeWeaponMod)) private fun decodeWeaponMod(reader: ProtoReader): WeaponMod { var id = 0u @@ -127,8 +125,7 @@ object Gfl2PayloadDecoder { var exp = 0u var suitFlags = 0uL - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field).toUInt() 2 -> stcId = reader.readUInt(field).toUInt() @@ -152,18 +149,8 @@ object Gfl2PayloadDecoder { ) } - private fun decodePublicSkillItems(reader: ProtoReader): PublicSkillItemsData { - val items = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - items += decodePublicSkillItem(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return PublicSkillItemsData(items) - } + private fun decodePublicSkillItems(reader: ProtoReader) = + PublicSkillItemsData(reader.collectMessages(1, ::decodePublicSkillItem)) private fun decodePublicSkillItem(reader: ProtoReader): PublicSkillItem { var id = 0uL @@ -171,8 +158,7 @@ object Gfl2PayloadDecoder { var gunId = 0u var lockedFlags = 0uL var isNew = false - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field) 2 -> stcId = reader.readUInt(field).toUInt() @@ -190,8 +176,7 @@ object Gfl2PayloadDecoder { var profile = PlatoonProfileData(0u, "", 0u, 0u) var questId = 0u var seasonId = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> profile = decodePlatoonProfileBody(reader.readMessage(field)) 2 -> questId = reader.readUInt(field).toUInt() @@ -212,8 +197,7 @@ object Gfl2PayloadDecoder { var bannerMarkId = 0u var declaration = "" var joinPolicyFlag = false - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> platoonId = reader.readUInt(field).toUInt() 2 -> platoonName = reader.readString(field).take(MAX_PLATOON_NAME_CHARS) @@ -242,18 +226,8 @@ object Gfl2PayloadDecoder { ) } - private fun decodeGuildMembers(reader: ProtoReader): GuildMembersData { - val members = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - members += decodeGuildMember(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return GuildMembersData(members) - } + private fun decodeGuildMembers(reader: ProtoReader) = + GuildMembersData(reader.collectMessages(1, ::decodeGuildMember)) private fun decodeGuildMember(reader: ProtoReader): GuildMember { var name = "" @@ -265,8 +239,7 @@ object Gfl2PayloadDecoder { var uid = 0u var lastLogin = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> { val player = decodePlayer(reader.readMessage(field)) @@ -289,8 +262,7 @@ object Gfl2PayloadDecoder { private fun decodePlatoonActivity(reader: ProtoReader): PlatoonActivityData { val summaries = mutableListOf() val entries = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> summaries += decodePlatoonActivitySummary(reader.readMessage(field)) 2 -> entries += decodePlatoonActivityEntry(reader.readMessage(field)) @@ -305,8 +277,7 @@ object Gfl2PayloadDecoder { var actionId = 0u var occurredAt = 0u var count = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field) 2 -> actionId = reader.readUInt(field).toUInt() @@ -323,8 +294,7 @@ object Gfl2PayloadDecoder { var occurredAt = 0u var actionId = 0u var memberName = "" - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 2 -> kind = reader.readUInt(field).toUInt() 3 -> occurredAt = reader.readUInt(field).toUInt() @@ -336,25 +306,14 @@ object Gfl2PayloadDecoder { return PlatoonActivityEntry(kind, occurredAt, actionId, memberName) } - private fun decodePlatoonUpdates(reader: ProtoReader): PlatoonUpdatesData { - val entries = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - entries += decodePlatoonUpdateEntry(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return PlatoonUpdatesData(entries) - } + private fun decodePlatoonUpdates(reader: ProtoReader) = + PlatoonUpdatesData(reader.collectMessages(1, ::decodePlatoonUpdateEntry)) private fun decodePlatoonUpdateEntry(reader: ProtoReader): PlatoonUpdateEntry { var kind = 0u val members = mutableListOf() var occurredAt = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> kind = reader.readUInt(field).toUInt() 2 -> members += decodePlatoonUpdateMember(reader.readMessage(field)) @@ -369,8 +328,7 @@ object Gfl2PayloadDecoder { var role = 0u var uid = 0u var name = "" - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> role = reader.readUInt(field).toUInt() 2 -> uid = reader.readUInt(field).toUInt() @@ -381,20 +339,13 @@ object Gfl2PayloadDecoder { return PlatoonUpdateMember(role, uid, name) } - private fun decodePlayer(reader: ProtoReader): Pair { - var result = "" to 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) result = decodePlayerInfo(reader.readMessage(field)) else reader.skip(field) - } - return result - } + private fun decodePlayer(reader: ProtoReader): Pair = + reader.collectMessages(1, ::decodePlayerInfo).lastOrNull() ?: ("" to 0u) private fun decodePlayerInfo(reader: ProtoReader): Pair { var name = "" var level = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 2 -> name = reader.readString(field) 3 -> level = reader.readUInt(field).toUInt() @@ -404,33 +355,15 @@ object Gfl2PayloadDecoder { return name to level } - private fun decodeFormationsResponse(reader: ProtoReader): FormationsData { - val formations = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - formations += decodeFormationsContainer(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return FormationsData(formations) - } - - private fun decodeFormationsContainer(reader: ProtoReader): List { - val formations = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) formations += decodeFormation(reader.readMessage(field)) else reader.skip(field) - } - return formations - } + private fun decodeFormationsResponse(reader: ProtoReader) = FormationsData( + reader.collectMessages(1) { container -> container.collectMessages(1, ::decodeFormation) } + .flatten(), + ) private fun decodeFormation(reader: ProtoReader): Formation { var name = "" val dolls = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> name = reader.readString(field) 2 -> dolls += decodeDoll(reader.readMessage(field)) @@ -448,8 +381,7 @@ object Gfl2PayloadDecoder { val expansionKeyIds = mutableListOf() val commonKeyUids = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> dollId = reader.readUInt(field).toUInt() 2 -> weaponUid = reader.readUInt(field) diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt index e4b220a..4a76063 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt @@ -36,18 +36,13 @@ class Gfl2StreamParser( } while (buffer.available >= OUTER_HEADER_SIZE) { - val messageId = buffer.peekUInt24Le(0) + // A 16-bit body length can never exceed one bounded outer message. val messageSize = buffer.peekUInt16Le(3) + OUTER_HEADER_SIZE - - if (messageSize > MAXIMUM_MESSAGE_SIZE) { - buffer.discard(1) - events += ParseEvent.Warning("Discarded an invalid message header declaring $messageSize bytes") - continue - } if (buffer.available < messageSize) break - val message = buffer.read(messageSize) - parseMessage(messageId, message, events) + // Parsed in place: decoding copies only recognized payload bodies. + parseMessage(buffer.peekUInt24Le(0), buffer.storage, buffer.readOffset, messageSize, events) + buffer.discard(messageSize) } return events @@ -69,55 +64,35 @@ class Gfl2StreamParser( private fun parseMessage( messageId: Int, - message: ByteArray, + bytes: ByteArray, + start: Int, + size: Int, events: MutableList, ) { - var offset = OUTER_HEADER_SIZE - while (offset < message.size) { - if (message.size - offset < PAYLOAD_HEADER_SIZE) { + val end = start + size + var offset = start + OUTER_HEADER_SIZE + while (offset < end) { + if (end - offset < PAYLOAD_HEADER_SIZE) { events += ParseEvent.Warning( "Message $messageId ends with an incomplete payload header", ) return } - val type = readUInt16Le(message, offset) - val payloadSize = readUInt16Le(message, offset + 2) + PAYLOAD_HEADER_SIZE - if (payloadSize > message.size - offset) { + val type = readUInt16Le(bytes, offset) + val payloadSize = readUInt16Le(bytes, offset + 2) + PAYLOAD_HEADER_SIZE + val supported = Gfl2PayloadDecoder.isSupported(type) + if (payloadSize > end - offset) { events += ParseEvent.Warning( - "Message $messageId payload $type declares $payloadSize bytes with ${message.size - offset} remaining", + "Message $messageId payload $type declares $payloadSize bytes with ${end - offset} remaining", ) + // The truncated frame is necessarily the last one in this message. + if (supported) discardDataset(type, messageId, isEndOfMessage = true, events) return } - val isEndOfMessage = offset + payloadSize >= message.size - if (type in Gfl2PayloadDecoder.supportedTypes) { - if (consumeQuarantinedPayload(type, messageId, isEndOfMessage)) { - offset += payloadSize - continue - } - val payload = message.copyOfRange( - offset + PAYLOAD_HEADER_SIZE, - offset + payloadSize, - ) - try { - val decoded = Gfl2PayloadDecoder.decode(type, payload) - if (decoded != null) { - acceptDecodedPayload( - messageId, - type, - isEndOfMessage, - payload.size, - decoded, - events, - ) - } - } catch (error: ProtocolException) { - events += ParseEvent.Warning( - "Unable to decode payload $type in message $messageId: ${error.message}", - ) - } - } else { + val isEndOfMessage = offset + payloadSize >= end + if (!supported) { emitPending(events) quarantinedPayload = null events += ParseEvent.UnknownPayload( @@ -126,6 +101,17 @@ class Gfl2StreamParser( payloadBytes = payloadSize - PAYLOAD_HEADER_SIZE, isEndOfMessage = isEndOfMessage, ) + } else if (!consumeQuarantinedPayload(type, messageId, isEndOfMessage)) { + val payload = bytes.copyOfRange(offset + PAYLOAD_HEADER_SIZE, offset + payloadSize) + try { + val decoded = requireNotNull(Gfl2PayloadDecoder.decode(type, payload)) + acceptDecodedPayload(messageId, type, isEndOfMessage, payload.size, decoded, events) + } catch (error: ProtocolException) { + events += ParseEvent.Warning( + "Unable to decode payload $type in message $messageId: ${error.message}", + ) + discardDataset(type, messageId, isEndOfMessage, events) + } } offset += payloadSize @@ -142,9 +128,9 @@ class Gfl2StreamParser( ) { val pending = pendingPayload if (pending != null) { - if (pending.type == type && - (pending.previousMessageId == 0 || pending.previousMessageId == messageId) - ) { + val continued = pending.type == type && continues(pending.previousMessageId, messageId) + val merged = if (continued) merge(pending.data, data) else null + if (continued && merged != null) { val accumulatedBytes = pending.accumulatedPayloadBytes + payloadBytes.toLong() val continuationCount = pending.continuationCount + 1 if ( @@ -152,7 +138,7 @@ class Gfl2StreamParser( continuationCount > maximumPendingContinuations ) { pendingPayload = null - quarantinedPayload = if (messageId != 0 && isEndOfMessage) { + quarantinedPayload = if (isTerminal(messageId, isEndOfMessage)) { null } else { QuarantinedPayload(type, messageId) @@ -162,26 +148,24 @@ class Gfl2StreamParser( ) return } - pending.data = merge(pending.data, data) + pending.data = merged pending.previousMessageId = messageId pending.isEndOfMessage = isEndOfMessage pending.accumulatedPayloadBytes = accumulatedBytes pending.continuationCount = continuationCount - if (messageId != 0 && isEndOfMessage) emitPending(events) + if (isTerminal(messageId, isEndOfMessage)) emitPending(events) return } + // A different dataset, or a continuation of a single-object payload such as + // 21905, keeps the earlier payload intact as its own event. emitPending(events) } - if (messageId != 0 && isEndOfMessage) { + if (isTerminal(messageId, isEndOfMessage)) { events += ParseEvent.Payload(ParsedPayload(messageId, type, true, data)) } else { if (payloadBytes > maximumPendingPayloadBytes) { - quarantinedPayload = if (messageId != 0 && isEndOfMessage) { - null - } else { - QuarantinedPayload(type, messageId) - } + quarantinedPayload = QuarantinedPayload(type, messageId) events += ParseEvent.Warning( "Discarded payload $type continuation sequence exceeding parser limits", ) @@ -198,18 +182,45 @@ class Gfl2StreamParser( } } + /** + * Drops the logical dataset a malformed frame belongs to. Merging its neighbours across the + * hole would publish an incomplete roster as if it were complete, so a continued pending + * payload is discarded and the dataset's remaining fragments are quarantined. A payload that + * cannot be merged, such as 21905, owns each frame and keeps its earlier, valid frames. + */ + private fun discardDataset( + type: Int, + messageId: Int, + isEndOfMessage: Boolean, + events: MutableList, + ) { + val mergeable = type != Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + val pending = pendingPayload + if ( + mergeable && pending != null && pending.type == type && + continues(pending.previousMessageId, messageId) + ) { + pendingPayload = null + } else { + emitPending(events) + } + if (!mergeable) return + quarantinedPayload = if (isTerminal(messageId, isEndOfMessage)) { + null + } else { + QuarantinedPayload(type, messageId) + } + } + private fun consumeQuarantinedPayload( type: Int, messageId: Int, isEndOfMessage: Boolean, ): Boolean { val quarantined = quarantinedPayload ?: return false - if ( - quarantined.type == type && - (quarantined.previousMessageId == 0 || quarantined.previousMessageId == messageId) - ) { + if (quarantined.type == type && continues(quarantined.previousMessageId, messageId)) { quarantined.previousMessageId = messageId - if (messageId != 0 && isEndOfMessage) quarantinedPayload = null + if (isTerminal(messageId, isEndOfMessage)) quarantinedPayload = null return true } quarantinedPayload = null @@ -229,7 +240,8 @@ class Gfl2StreamParser( pendingPayload = null } - private fun merge(first: GameData, second: GameData): GameData = when { + /** Returns null for payloads that are not lists and therefore cannot be concatenated. */ + private fun merge(first: GameData, second: GameData): GameData? = when { first is WeaponsData && second is WeaponsData -> WeaponsData(first.weapons + second.weapons) first is WeaponModsData && second is WeaponModsData -> WeaponModsData(first.mods + second.mods) @@ -246,9 +258,17 @@ class Gfl2StreamParser( PlatoonUpdatesData(first.entries + second.entries) first is FormationsData && second is FormationsData -> FormationsData(first.formations + second.formations) - else -> second + else -> null } + /** The reference continuation rule: after message id 0, or within the same message id. */ + private fun continues(previousMessageId: Int, messageId: Int): Boolean = + previousMessageId == 0 || previousMessageId == messageId + + /** A frame with a non-zero message id that ends its message completes a logical payload. */ + private fun isTerminal(messageId: Int, isEndOfMessage: Boolean): Boolean = + messageId != 0 && isEndOfMessage + private fun readUInt16Le(bytes: ByteArray, offset: Int): Int = (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) @@ -256,7 +276,6 @@ class Gfl2StreamParser( private companion object { const val OUTER_HEADER_SIZE = 5 const val PAYLOAD_HEADER_SIZE = 4 - const val MAXIMUM_MESSAGE_SIZE = 65_540 const val DEFAULT_MAXIMUM_BUFFERED_BYTES = 2 * 1024 * 1024 const val DEFAULT_MAXIMUM_PENDING_PAYLOAD_BYTES = 2 * 1024 * 1024 const val DEFAULT_MAXIMUM_PENDING_CONTINUATIONS = 64 diff --git a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt index 491f0c6..6cf4c3e 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt @@ -1,5 +1,6 @@ package dev.gf2log.protocol +import dev.gf2log.protocol.internal.CsvRecords import dev.gf2log.protocol.model.GuildMember object GuildMembersCsv { @@ -64,7 +65,8 @@ object GuildMembersCsv { // - Returns null when any required field is malformed or capture times disagree. fun parse(content: String): Snapshot? { if (content.length > MAX_CONTENT_CHARS) return null - val records = parseRecords(content) ?: return null + val records = CsvRecords.parse(content, MAX_COLUMNS, MAX_RECORDS, MAX_FIELD_CHARS) + ?: return null val header = records.firstOrNull() ?: return null if (header != HEADER.split(',')) return null @@ -96,59 +98,6 @@ object GuildMembersCsv { return Snapshot(logTime = snapshotTime, members = members) } - private fun parseRecords(content: String): List>? { - val csv = content.replace("\r\n", "\n").replace('\r', '\n') - val records = mutableListOf>() - val row = mutableListOf() - val field = StringBuilder() - var quoted = false - var index = 0 - - while (index < csv.length) { - when (val character = csv[index]) { - '"' -> { - if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { - field.append('"') - index += 1 - } else { - quoted = !quoted - } - } - ',' -> if (quoted) { - field.append(character) - } else { - if (row.size >= MAX_COLUMNS || field.length > MAX_FIELD_CHARS) return null - row += field.toString() - field.clear() - } - '\n' -> if (quoted) { - field.append(character) - } else { - if (row.size >= MAX_COLUMNS || field.length > MAX_FIELD_CHARS) return null - row += field.toString() - field.clear() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - row.clear() - } - else -> { - if (field.length >= MAX_FIELD_CHARS) return null - field.append(character) - } - } - index += 1 - } - - if (quoted) return null - if (field.isNotEmpty() || row.isNotEmpty()) { - if (row.size >= MAX_COLUMNS || field.length > MAX_FIELD_CHARS) return null - row += field.toString() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - } - return records - } - // Function Name: parseOptionalCounter // Description: // - Matches protobuf scalar semantics by treating an omitted counter as zero. diff --git a/protocol/src/main/java/dev/gf2log/protocol/LogTime.kt b/protocol/src/main/java/dev/gf2log/protocol/LogTime.kt new file mode 100644 index 0000000..2012984 --- /dev/null +++ b/protocol/src/main/java/dev/gf2log/protocol/LogTime.kt @@ -0,0 +1,14 @@ +package dev.gf2log.protocol + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** UTC second-precision timestamp shared by roster `logTime` cells and history envelopes. */ +object LogTime { + private val FORMAT: DateTimeFormatter = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + + fun format(instant: Instant): String = FORMAT.format(instant) +} diff --git a/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt b/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt index 7d97e58..5b53c62 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt @@ -1,13 +1,12 @@ package dev.gf2log.protocol +import dev.gf2log.protocol.internal.CsvRecords + object ParsedPacketTableParser { fun parse(content: String): Table? { if (content.length > MAX_CONTENT_CHARS) return null - val normalized = content.replace("\r\n", "\n").replace('\r', '\n') - val separator = normalized.indexOf("\n\n") - if (separator < 0 || separator + 2 >= normalized.length) return null - - val records = parseCsv(normalized.substring(separator + 2)) ?: return null + val body = csvBody(content) ?: return null + val records = CsvRecords.parse(body, MAX_COLUMNS, MAX_RECORDS, MAX_CELL_CHARS) ?: return null val header = records.firstOrNull() ?.takeIf { it.isNotEmpty() && it.size <= MAX_COLUMNS } ?: return null @@ -22,52 +21,12 @@ object ParsedPacketTableParser { ) } - private fun parseCsv(csv: String): List>? { - val records = mutableListOf>() - val row = mutableListOf() - val field = StringBuilder() - var quoted = false - var index = 0 - - while (index < csv.length) { - when (val character = csv[index]) { - '"' -> { - if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { - field.append('"') - index += 1 - } else { - quoted = !quoted - } - } - ',' -> if (quoted) field.append(character) else { - if (row.size >= MAX_COLUMNS || field.length > MAX_CELL_CHARS) return null - row += field.toString() - field.clear() - } - '\n' -> if (quoted) field.append(character) else { - if (row.size >= MAX_COLUMNS || field.length > MAX_CELL_CHARS) return null - row += field.toString() - field.clear() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - row.clear() - } - else -> { - if (field.length >= MAX_CELL_CHARS) return null - field.append(character) - } - } - index += 1 - } - - if (quoted) return null - if (field.isNotEmpty() || row.isNotEmpty()) { - if (row.size >= MAX_COLUMNS || field.length > MAX_CELL_CHARS) return null - row += field.toString() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - } - return records + /** Returns the line-ending-normalized CSV after a history envelope's blank separator line. */ + fun csvBody(content: String): String? { + val normalized = CsvRecords.normalizeLineEndings(content) + val separator = normalized.indexOf("\n\n") + if (separator < 0 || separator + 2 >= normalized.length) return null + return normalized.substring(separator + 2) } data class Table( diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt index 4c424d6..a5c6ed2 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt @@ -12,13 +12,20 @@ internal class ByteAccumulator( val available: Int get() = writePosition - readPosition + /** Backing array holding [available] unread bytes from [readOffset]; valid until mutated. */ + val storage: ByteArray + get() = bytes + + val readOffset: Int + get() = readPosition + fun append(source: ByteArray) { if (source.isEmpty()) return if (source.size > maximumSize - available) { throw ProtocolException("Buffered stream data exceeds $maximumSize bytes") } - ensureCapacity(available + source.size) + ensureCapacity(source.size) source.copyInto(bytes, writePosition) writePosition += source.size } @@ -38,18 +45,10 @@ internal class ByteAccumulator( ((bytes[position + 2].toInt() and 0xFF) shl 16) } - fun read(count: Int): ByteArray { - requireAvailable(0, count) - val result = bytes.copyOfRange(readPosition, readPosition + count) - readPosition += count - compactIfNeeded() - return result - } - fun discard(count: Int) { requireAvailable(0, count) readPosition += count - compactIfNeeded() + if (readPosition == writePosition) clear() } fun clear() { @@ -57,20 +56,17 @@ internal class ByteAccumulator( writePosition = 0 } - private fun ensureCapacity(required: Int) { - if (required <= bytes.size - readPosition) { - if (readPosition > 0) compact() + /** Compacts or grows only when [extra] bytes do not fit after the current write position. */ + private fun ensureCapacity(extra: Int) { + if (writePosition + extra <= bytes.size) return + val required = available + extra + if (required <= bytes.size) { + compact() return } var newSize = bytes.size - while (newSize < required) { - newSize = minOf(maximumSize, newSize * 2) - if (newSize < required && newSize == maximumSize) { - throw ProtocolException("Unable to grow stream buffer to $required bytes") - } - } - + while (newSize < required) newSize = minOf(maximumSize, newSize * 2) val replacement = ByteArray(newSize) bytes.copyInto(replacement, 0, readPosition, writePosition) writePosition = available @@ -78,14 +74,6 @@ internal class ByteAccumulator( bytes = replacement } - private fun compactIfNeeded() { - if (readPosition == writePosition) { - clear() - } else if (readPosition >= bytes.size / 2) { - compact() - } - } - private fun compact() { val length = available bytes.copyInto(bytes, 0, readPosition, writePosition) diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt new file mode 100644 index 0000000..758b11a --- /dev/null +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt @@ -0,0 +1,66 @@ +package dev.gf2log.protocol.internal + +/** Bounded RFC-4180-style tokenizer shared by every CSV reader in this module. */ +internal object CsvRecords { + /** Returns null for unbalanced quotes or when any bound would be exceeded. */ + fun parse( + content: String, + maxColumns: Int, + maxRecords: Int, + maxFieldChars: Int, + ): List>? { + val csv = normalizeLineEndings(content) + val records = mutableListOf>() + val row = mutableListOf() + val field = StringBuilder() + var quoted = false + var index = 0 + + fun endField(): Boolean { + if (row.size >= maxColumns || field.length > maxFieldChars) return false + row += field.toString() + field.clear() + return true + } + + fun endRecord(): Boolean { + if (records.size >= maxRecords) return false + records += row.toList() + row.clear() + return true + } + + while (index < csv.length) { + when (val character = csv[index]) { + '"' -> { + if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { + field.append('"') + index += 1 + } else { + quoted = !quoted + } + } + ',' -> if (quoted) field.append(character) else if (!endField()) return null + '\n' -> if (quoted) { + field.append(character) + } else if (!endField() || !endRecord()) { + return null + } + else -> { + if (field.length >= maxFieldChars) return null + field.append(character) + } + } + index += 1 + } + + if (quoted) return null + if (field.isNotEmpty() || row.isNotEmpty()) { + if (!endField() || !endRecord()) return null + } + return records + } + + fun normalizeLineEndings(content: String): String = + content.replace("\r\n", "\n").replace('\r', '\n') +} diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt index 8b55690..f26fd43 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt @@ -109,3 +109,20 @@ internal class ProtoReader( } } } + +/** Visits every field until the message is exhausted; handlers must read or [ProtoReader.skip] it. */ +internal inline fun ProtoReader.forEachField(action: (ProtoField) -> Unit) { + while (true) action(nextField() ?: return) +} + +/** Decodes every [fieldNumber] submessage and skips all other fields. */ +internal inline fun ProtoReader.collectMessages( + fieldNumber: Int, + decode: (ProtoReader) -> T, +): List { + val items = mutableListOf() + forEachField { field -> + if (field.number == fieldNumber) items += decode(readMessage(field)) else skip(field) + } + return items +} diff --git a/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt b/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt new file mode 100644 index 0000000..4553b0d --- /dev/null +++ b/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt @@ -0,0 +1,24 @@ +package dev.gf2log.protocol + +import dev.gf2log.protocol.internal.CsvRecords +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CsvRecordsTest { + @Test + fun quotedDelimitersLineBreaksAndEscapedQuotesStayInOneField() { + assertEquals( + listOf(listOf("a,b", "line\nbreak", "say \"hi\""), listOf("2", "", "")), + CsvRecords.parse("\"a,b\",\"line\r\nbreak\",\"say \"\"hi\"\"\"\r\n2,,", 3, 2, 32), + ) + } + + @Test + fun everyBoundAndUnbalancedQuoteRejectsTheDocument() { + assertNull(CsvRecords.parse("a,b,c", maxColumns = 2, maxRecords = 1, maxFieldChars = 8)) + assertNull(CsvRecords.parse("a\nb\nc", maxColumns = 1, maxRecords = 2, maxFieldChars = 8)) + assertNull(CsvRecords.parse("abcdef", maxColumns = 1, maxRecords = 1, maxFieldChars = 5)) + assertNull(CsvRecords.parse("\"open", maxColumns = 1, maxRecords = 1, maxFieldChars = 8)) + } +} diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt index 3d2ea3f..1a4b947 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt @@ -211,6 +211,42 @@ class Gfl2StreamParserTest { assertEquals(listOf(1u, 2u), members.map { it.uid }) } + @Test + fun unmergeableProfileContinuationKeepsBothPayloads() { + val parser = Gfl2StreamParser() + val first = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "First")), + ) + val last = outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "Second")), + ) + + assertTrue(parser.accept(first).isEmpty()) + val payloads = parser.accept(last).filterIsInstance() + + assertEquals( + listOf("First", "Second"), + payloads.map { (it.value.data as PlatoonProfileData).platoonName }, + ) + assertEquals(listOf(0, 42), payloads.map { it.value.messageId }) + assertTrue(parser.finish().isEmpty()) + } + + @Test + fun unmergeablePendingPayloadSurvivesALaterContinuation() { + val parser = Gfl2StreamParser() + val profile = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "Only")), + ) + + assertTrue(parser.accept(profile).isEmpty()) + assertTrue(parser.accept(profile).filterIsInstance().size == 1) + assertEquals(1, parser.finish().filterIsInstance().size) + } + @Test fun pendingContinuationIsEmittedWhenFlowFinishes() { val parser = Gfl2StreamParser() @@ -352,6 +388,83 @@ class Gfl2StreamParserTest { assertEquals("Recovered", (recovered.value.data as GuildMembersData).members.single().name) } + @Test + fun malformedContinuationDiscardsTheWholeDatasetInsteadOfMergingAroundIt() { + val parser = Gfl2StreamParser() + val first = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("First", 1uL)), + ) + val malformed = outerMessage(0, payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, MALFORMED)) + val last = outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Last", 3uL)), + ) + + assertTrue(parser.accept(first).isEmpty()) + assertEquals(1, parser.accept(malformed).filterIsInstance().size) + assertTrue(parser.accept(last).isEmpty()) + assertTrue(parser.finish().isEmpty()) + assertRecovers(parser) + } + + @Test + fun malformedFirstFragmentQuarantinesItsTail() { + val parser = Gfl2StreamParser() + val events = parser.accept( + outerMessage(0, payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, MALFORMED)) + + outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Tail", 2uL)), + ), + ) + + assertEquals(1, events.filterIsInstance().size) + assertTrue(events.filterIsInstance().isEmpty()) + assertRecovers(parser) + } + + @Test + fun truncatedContinuationFrameDiscardsTheWholeDataset() { + val parser = Gfl2StreamParser() + val first = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("First", 1uL)), + ) + val truncated = outerMessageWithBody( + 0, + littleEndian16(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) + littleEndian16(100) + byteArrayOf(0x0A), + ) + val last = outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Last", 3uL)), + ) + + assertTrue(parser.accept(first).isEmpty()) + val events = parser.accept(truncated + last) + + assertEquals(1, events.filterIsInstance().size) + assertTrue(events.filterIsInstance().isEmpty()) + assertRecovers(parser) + } + + @Test + fun malformedProfileFrameKeepsTheEarlierIndependentProfile() { + val parser = Gfl2StreamParser() + val valid = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "Kept")), + ) + val malformed = outerMessage(0, payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, MALFORMED)) + + assertTrue(parser.accept(valid).isEmpty()) + val events = parser.accept(malformed) + + assertEquals(1, events.filterIsInstance().size) + assertEquals("Kept", (events.singlePayload().value.data as PlatoonProfileData).platoonName) + assertTrue(parser.finish().isEmpty()) + } + @Test fun unknownPayloadsRemainObservableWithoutRetainingTheirBytes() { val parser = Gfl2StreamParser() @@ -414,6 +527,9 @@ class Gfl2StreamParserTest { return messageField(1, member) } + private fun platoonProfilePayload(id: ULong, name: String): ByteArray = + messageField(1, uintField(1, id) + stringField(2, name)) + private fun formationsPayload(): ByteArray { val doll = uintField(1, 12uL) + uintField(2, 900uL) + @@ -449,6 +565,16 @@ class Gfl2StreamParserTest { return messageField(1, entry) } + private fun assertRecovers(parser: Gfl2StreamParser) { + val recovered = parser.accept( + outerMessage( + 43, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Fresh", 9uL)), + ), + ).singlePayload() + assertEquals("Fresh", (recovered.value.data as GuildMembersData).members.single().name) + } + private fun List.singlePayload(): ParseEvent.Payload = filterIsInstance().single() @@ -499,4 +625,9 @@ class Gfl2StreamParserTest { value.toByte(), (value ushr 8).toByte(), ) + + private companion object { + /** Field 1 declares 127 bytes that are not present. */ + val MALFORMED = byteArrayOf(0x0A, 0x7F) + } } diff --git a/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt b/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt index d300913..e28628e 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt @@ -6,6 +6,13 @@ import org.junit.Assert.assertTrue import org.junit.Test class PayloadCatalogTest { + @Test + fun `hot path support check matches the supported type set`() { + (0..0xFFFF).forEach { type -> + assertEquals(type in Gfl2PayloadDecoder.supportedTypes, Gfl2PayloadDecoder.isSupported(type)) + } + } + @Test fun `catalog names every supported payload type`() { assertEquals(Gfl2PayloadDecoder.supportedTypes, PayloadCatalog.categories.map { it.payloadType }.toSet()) diff --git a/protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt b/protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt new file mode 100644 index 0000000..c3babcb --- /dev/null +++ b/protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt @@ -0,0 +1,36 @@ +package dev.gf2log.protocol.internal + +import dev.gf2log.protocol.ProtocolException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ByteAccumulatorTest { + @Test + fun compactsAndGrowsWithoutLosingUnreadBytes() { + val buffer = ByteAccumulator(maximumSize = 1_024) + buffer.append(ByteArray(200) { it.toByte() }) + buffer.discard(150) + // 50 unread + 100 new fits the initial 256-byte array only after compaction. + buffer.append(ByteArray(100) { (200 + it).toByte() }) + assertEquals(150, buffer.available) + assertEquals(150 or (151 shl 8), buffer.peekUInt16Le(0)) + + buffer.append(ByteArray(600) { 7 }) + assertEquals(750, buffer.available) + assertEquals(150, buffer.storage[buffer.readOffset].toInt() and 0xFF) + assertEquals(7, buffer.storage[buffer.readOffset + 749].toInt()) + + buffer.discard(750) + assertEquals(0, buffer.available) + assertEquals(0, buffer.readOffset) + } + + @Test + fun rejectsInputBeyondTheBound() { + val buffer = ByteAccumulator(maximumSize = 64) + buffer.append(ByteArray(60)) + assertTrue(runCatching { buffer.append(ByteArray(5)) }.exceptionOrNull() is ProtocolException) + assertEquals(60, buffer.available) + } +}