diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8e93d14..a9454cc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -21,16 +21,16 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Make Gradle wrapper executable run: chmod +x gradlew @@ -68,7 +68,7 @@ jobs: mkdir -p ci-artifacts cp "${apk}" \ - ci-artifacts/mobileGF2logger-v2.1.0-unsigned-verification-only.apk + "ci-artifacts/mobileGF2logger-${GITHUB_SHA::12}-unsigned-verification-only.apk" cat > ci-artifacts/DO_NOT_DISTRIBUTE.txt <<'EOF' This unsigned APK exists only to verify compilation, lint, native packaging, minification, and resource shrinking in CI. It cannot be @@ -76,7 +76,7 @@ jobs: EOF - name: Upload unsigned verification artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unsigned-verification-only-do-not-distribute path: ci-artifacts/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b9b9fd6..da23826 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,16 +28,16 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Make Gradle wrapper executable run: chmod +x gradlew @@ -47,7 +47,7 @@ jobs: "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "cmake;3.22.1" - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: c-cpp build-mode: manual @@ -62,7 +62,7 @@ jobs: --stacktrace - name: Analyze C/C++ - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: category: "/language:c-cpp" @@ -77,22 +77,22 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Make Gradle wrapper executable run: chmod +x gradlew - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: java-kotlin build-mode: manual @@ -107,6 +107,6 @@ jobs: --stacktrace - name: Analyze Kotlin - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: category: "/language:java-kotlin" diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 4270a8a..097d5a5 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -22,11 +22,11 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure GitHub Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - name: Prepare Pages source shell: bash @@ -203,7 +203,7 @@ jobs: 'google-site-verification: google1d4c3f230e6af424.html' - name: Build with Jekyll - uses: actions/jekyll-build-pages@v1 + uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1 with: source: ./.pages-src destination: ./_site @@ -235,7 +235,7 @@ jobs: 'google-site-verification: google1d4c3f230e6af424.html' - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: ./_site @@ -252,4 +252,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9a9d1..b4ad2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ All notable changes to mobileGF2logger are documented here. ## Unreleased +### Added + +- Make every weekly metric cell explain its value, certainty, and supporting + facts when tapped, and add an Evidence Health panel summarizing observed, + exact, lower-bound, unknown, Login, Daily Patrol, and closing-boundary data. +- Add guided capture with a live Members, Activity, and Updates checklist that + stops automatically when all three management payloads have arrived. +- Preview the effects of one or more roster CSV files before mutation, create a + private automatic checkpoint before import, and provide one-level undo for + the most recent confirmed import. +- Export the displayed weekly table as a bounded PNG or share it through + Android, with independent controls for member names, UIDs, and private notes. + UIDs and private notes remain excluded by default. +- Send a parsed packet's validated original CSV body to a user-configured + Discord incoming webhook after explicit confirmation. The webhook is stored + with Android Keystore encryption and is never displayed after saving. +- Add a security policy with private-reporting and data-redaction guidance. + +### Changed + +- Move weekly navigation and stale-result validity into a lifecycle-independent + state holder, persist the selected week across recreation, and recycle heavy + weekly member rows in a bounded `RecyclerView` viewport. +- Extract standard-week counter inference from report assembly and extract + weekly snapshot projection SQL from the schema and transaction helper. +- Pin every GitHub Action to a verified commit SHA and make unsigned CI artifact + names commit-specific instead of carrying a stale release version. +- Add deterministic randomized invariants for standard-week inference and + malformed protocol streams alongside focused tests for evidence, sharing, + guided capture, import preview, checkpoint identity, and webhook policy. +- Clarify guided capture, stop, and Discord actions with accessible color, + Discord icons, aligned packet actions, and an explicit webhook-availability + indicator. + +### Fixed + +- Reject stale asynchronous weekly renders after navigation, pause, or screen + recreation and preserve the selected reporting date instead of allowing + overlapping week loads to replace each other. +- Keep newly selected CSV sources unretained until the user confirms the impact + preview, reconcile crash-left evidence before classification, cap multi-file + imports, preserve the preceding undo until a replacement import seals, and + recover database/quarantine state after a failed import, undo, or process death. +- Bound weekly PNG dimensions, pixel count, row count, and private-note length; + share only through a cache-scoped non-exported `FileProvider` grant. +- Restrict Discord destinations to canonical HTTPS `discord.com` incoming + webhook URLs, disallow redirects, and bound request/response sizes and timeouts. + ## 2.1.2 - 2026-08-09 ### Changed diff --git a/README.md b/README.md index 2832897..1de58c9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ capture to the selected game package, and raw traffic is never stored. - Captures the mandatory Members (`21917`), Activity (`21935`), and Updates (`21960`) responses without a computer or root access. - Tracks active and withdrawn members, repeat membership periods, exact Updates timestamps, editable nicknames, and private notes. - Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the 05:00 game reset, with cut-off points and manual correction for missing data. +- Offers guided capture that tracks the three 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. +- Previews roster CSV impact before import and keeps an automatic one-level checkpoint for undo. +- 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 complete `.gf2backup` export/restore. - Supports English and Korean and uses the Android device timezone for display. @@ -36,3 +41,9 @@ uid,name,level,weeklyMerit,totalMerit,highScore,totalScore,lastLogin,logTime 5. Return to GF2logger to review the captured packets and Platoon data. The app keeps parsed history, management data, and generated CSV files in private on-device storage. It does not bypass TLS, certificate pinning, or anti-cheat systems, and it does not modify game traffic. Server responses may contain only recent incremental history, so older missing membership records can be entered manually. + +## Inspiration + +mobileGF2logger was inspired by [gfl2logger](https://github.com/blead/gfl2logger), +a GF2 logger for the Windows client. This project is an independent Android +implementation tailored to on-device Platoon management. diff --git a/README_KR.md b/README_KR.md index b3f09ce..b06e59b 100644 --- a/README_KR.md +++ b/README_KR.md @@ -13,6 +13,11 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 컴퓨터나 루트 권한 없이 필수 멤버(`21917`), 활동(`21935`), 동향(`21960`) 응답을 캡처합니다. - 가입 및 탈퇴 멤버, 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다. - 05:00 게임 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다. +- 안내형 캡처는 세 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다. +- 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다. +- 명단 CSV를 가져오기 전에 영향을 미리 보여 주고, 실행 취소용 1단계 자동 체크포인트를 보관합니다. +- 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다. +- 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. - 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 서클 관리 데이터 백업 및 복원을 지원합니다. - 영어와 한국어를 지원하며 화면에 표시하는 시각에는 Android 기기의 시간대를 사용합니다. @@ -31,3 +36,7 @@ uid,name,level,weeklyMerit,totalMerit,highScore,totalScore,lastLogin,logTime 5. GF2logger로 돌아와 캡처한 패킷과 서클 데이터를 확인합니다. 앱은 파싱 내역, 관리 데이터, 생성한 CSV 파일을 기기의 비공개 저장소에 보관합니다. TLS, 인증서 고정 또는 안티치트 체계를 우회하지 않으며 게임 트래픽을 변경하지 않습니다. 서버 응답에는 최근의 일부 이력만 포함될 수 있으므로, 누락된 과거 가입 이력은 직접 추가할 수 있습니다. + +## 영감 및 감사 + +mobileGF2logger는 Windows 클라이언트용 GF2 로거인 [gfl2logger](https://github.com/blead/gfl2logger)에서 영감을 받은, Android 기기 내 서클 관리에 맞춘 독립적인 구현입니다. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b930f9b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security policy + +## Supported versions + +Security fixes are made on the latest published release and the current +`main` branch. Older releases should be upgraded before a report is evaluated. + +## Reporting a vulnerability + +Please do not publish exploit details, captured player data, backup archives, +or packet payloads in a public issue. + +Use the repository's **Security** tab to submit a private vulnerability report +when that option is available. If private reporting is unavailable, contact the +maintainer through the GitHub profile linked from the repository and request a +private channel without including sensitive details in the first message. + +Include: + +- the affected version or commit; +- the Android version and device architecture; +- the smallest reproducible input using synthetic data; +- the expected and observed behavior; +- the impact and any user interaction required; and +- whether the issue affects capture, parsing, CSV import/export, backup/restore, + Discord sharing, local persistence, or release signing. + +Do not test against another person's device, game account, Discord webhook, or +network traffic. Remove UIDs, names, notes, webhook secrets, signing material, +and real packet contents from reproductions. + +## Security boundaries + +mobileGF2logger observes traffic only after Android grants `VpnService` consent +for a user-selected package. It does not decrypt TLS or authenticate the game's +plaintext protocol. Parsed data and backups therefore provide local integrity +and management convenience, not cryptographic proof that a remote payload is +genuine. + +Exports and Discord sends are explicit user actions that move selected data out +of Android private storage. Backups are checksummed and strictly validated but +are not encrypted or signed. Treat exported files and webhook destinations as +sensitive. + +## Response expectations + +The maintainer will acknowledge a reproducible report, assess severity, and +coordinate a fix and disclosure timeline. A report may be closed when it is a +duplicate, requires a rooted/compromised Android system outside this project's +control, or does not cross a documented security boundary. diff --git a/app/build.gradle b/app/build.gradle index 10bf74c..29d1e1f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20102 - versionName = '2.1.2' + versionCode = 20200 + versionName = '2.2.0' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { @@ -223,7 +223,9 @@ android { dependencies { implementation project(':protocol') + implementation 'androidx.core:core:1.17.0' + implementation 'androidx.recyclerview:recyclerview:1.4.0' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test:runner:1.6.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:runner:1.7.0' + androidTestImplementation 'androidx.test.ext:junit:1.3.0' } 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 63e2be8..cad7c25 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -425,6 +425,67 @@ class PlatoonBackupManagerIntegrationTest { } } + @Test + fun failedCsvImportPreservesThePreviousSuccessfulUndoCheckpoint() { + seedDatabase(ARCHIVED_UID, "Before first import", "before-first-import.csv") + val firstImport = CsvImportCheckpointManager(context) + firstImport.create(emptySet()) + replaceDatabaseForCheckpoint(CURRENT_UID, "After first import", "after-first-import.csv") + firstImport.seal() + + val failedImport = CsvImportCheckpointManager(context) + failedImport.create(emptySet()) + replaceDatabaseForCheckpoint(THIRD_UID, "Failed second import", "failed-import.csv") + failedImport.rollbackFailedImport() + + PlatoonDatabase(context).use { database -> + val db = database.readableDatabase + assertEquals(1L, count(db, "members", "uid = ?", CURRENT_UID)) + assertEquals(0L, count(db, "members", "uid = ?", THIRD_UID)) + } + + val previousUndo = CsvImportCheckpointManager(context) + assertTrue(previousUndo.canUndo()) + previousUndo.restore() + + PlatoonDatabase(context).use { database -> + val db = database.readableDatabase + assertEquals(1L, count(db, "members", "uid = ?", ARCHIVED_UID)) + assertEquals(0L, count(db, "members", "uid = ?", CURRENT_UID)) + } + assertFalse(CsvImportCheckpointManager(context).canUndo()) + } + + @Test + fun interruptedCsvUndoCompletesAfterDatabaseInstallationOnRestart() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + val manager = CsvImportCheckpointManager(context) { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + throw SimulatedProcessDeath() + } + } + manager.create(emptySet()) + replaceDatabaseForCheckpoint(CURRENT_UID, "After import", "after-import.csv") + manager.seal() + + assertThrows(SimulatedProcessDeath::class.java) { + manager.restore() + } + + val recovered = CsvImportCheckpointManager(context) + + assertFalse(recovered.canUndo()) + PlatoonDatabase(context).use { database -> + val db = database.readableDatabase + assertEquals(1L, count(db, "members", "uid = ?", ARCHIVED_UID)) + assertEquals(0L, count(db, "members", "uid = ?", CURRENT_UID)) + } + assertFalse(FilePaths.csvCheckpointDirectory(context).exists()) + assertFalse(FilePaths.csvCheckpointStagingDirectory(context).exists()) + assertFalse(FilePaths.csvCheckpointPreviousDirectory(context).exists()) + assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) + } + @Test fun malformedCompleteBackupDoesNotMutateExistingState() { seedDatabase(CURRENT_UID, "Current member", "current-source.csv") @@ -446,10 +507,14 @@ class PlatoonBackupManagerIntegrationTest { } private fun replaceDatabaseWithCurrentState() { + replaceDatabaseForCheckpoint(CURRENT_UID, "Current member", "current-source.csv") + } + + private fun replaceDatabaseForCheckpoint(uid: Long, name: String, sourceFile: String) { PlatoonRepository.withExclusiveDatabase { context.deleteDatabase(PlatoonSchema.DATABASE_NAME) } - seedDatabase(CURRENT_UID, "Current member", "current-source.csv") + seedDatabase(uid, name, sourceFile) } private fun seedDatabase(uid: Long, name: String, sourceFile: String) { @@ -636,8 +701,12 @@ class PlatoonBackupManagerIntegrationTest { } context.getSharedPreferences(USER_SETTINGS, Context.MODE_PRIVATE).edit().clear().commit() FilePaths.restoreDirectory(context).deleteRecursively() + FilePaths.restoreTransactionDirectory(context).deleteRecursively() FilePaths.retainedCsvDirectory(context).deleteRecursively() FilePaths.previousRetainedCsvDirectory(context).deleteRecursively() + FilePaths.csvCheckpointDirectory(context).deleteRecursively() + FilePaths.csvCheckpointStagingDirectory(context).deleteRecursively() + FilePaths.csvCheckpointPreviousDirectory(context).deleteRecursively() } private object FilePaths { @@ -671,12 +740,24 @@ class PlatoonBackupManagerIntegrationTest { context.getDatabasePath(PlatoonSchema.DATABASE_NAME).parentFile, "${PlatoonSchema.DATABASE_NAME}.pre_restore", ) + + fun csvCheckpointDirectory(context: Context) = + java.io.File(context.filesDir, "csv-import-checkpoint") + + fun csvCheckpointStagingDirectory(context: Context) = + java.io.File(context.filesDir, "csv-import-checkpoint.staging") + + fun csvCheckpointPreviousDirectory(context: Context) = + java.io.File(context.filesDir, "csv-import-checkpoint.previous") } + private class SimulatedProcessDeath : Error() + private companion object { const val USER_SETTINGS = "user_settings" const val ARCHIVED_UID = 1001L const val CURRENT_UID = 2002L + const val THIRD_UID = 3003L const val ARCHIVED_NOTE = "Weekly review" const val INVALID_TARGET_PACKAGE = "not a package" val PERIOD_START: LocalDate = LocalDate.of(2026, 7, 26) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc688bd..033493e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,16 @@ android:name=".MemberDetailActivity" android:exported="false" /> + + + + ) { + // - Unit after scheduling validation and preview display. + private fun preparePlatoonCsvSources(sources: List) { + if (CaptureStatus.isRunning) { + statusText.text = getString(R.string.stop_capture_before_csv_import) + return + } + statusText.text = getString(R.string.csv_import_preparing_preview) fileIoExecutor.execute { val result = runCatching { require(sources.size <= MAX_CSV_IMPORT_FILES) { @@ -403,35 +459,132 @@ class MainActivity : LocalizedActivity() { } val directory = File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) 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) + 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(unique, duplicateNames, preview) + } + statusHandler.post { + if (isFinishing || isDestroyed) return@post + result.fold( + onSuccess = { + pendingCsvImport = it + showCsvImportPreview(it) + }, + onFailure = { + pendingCsvImport = null + statusText.text = getString(R.string.status_platoon_csv_import_failed) + }, + ) + } + } + } + + 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(), + ) + } + AlertDialog.Builder(this) + .setTitle(R.string.csv_import_preview_title) + .setMessage( + 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 } + .setPositiveButton(R.string.import_platoon_csv) { _, _ -> + applyPreparedCsvImport(pending) + } + .setOnCancelListener { pendingCsvImport = null } + .show() + } + + private fun applyPreparedCsvImport(pending: PendingCsvImport) { + if (pendingCsvImport !== pending || CaptureStatus.isRunning) { + pendingCsvImport = null + statusText.text = getString(R.string.stop_capture_before_csv_import) + return + } + pendingCsvImport = null + statusText.text = getString(R.string.csv_import_applying) + fileIoExecutor.execute { + val directory = File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + val store = PlatoonCsvImportStore(directory) + val checkpoint = CsvImportCheckpointManager(this) + 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 - val createdFiles = mutableListOf() - val imported = try { - sources.forEach { source -> - val input = TrustedImportSource.openInputStream(contentResolver, source) - ?: error("Document provider did not open an input stream") - input.use(store::retain).also { - if (it.duplicate) { - duplicates += 1 - } else { - retained += 1 - createdFiles += it.file - } - } + pending.prepared.forEach { prepared -> + store.retain(prepared).also { + if (it.duplicate) duplicates += 1 else retained += 1 } - PlatoonRepository(this).reconcileRetainedCsvFiles(directory) - } catch (error: Exception) { - createdFiles.forEach(File::delete) - throw error } + val imported = PlatoonRepository(this).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 statusText.text = result.fold( onSuccess = { summary -> getString( - R.string.status_platoon_csv_imported, + R.string.status_platoon_csv_imported_with_checkpoint, summary.retained, summary.imported.imported, summary.imported.historical, @@ -444,6 +597,35 @@ class MainActivity : LocalizedActivity() { } } + private fun confirmUndoLastCsvImport() { + if (CaptureStatus.isRunning) { + statusText.text = getString(R.string.stop_capture_before_csv_import) + return + } + if (!CsvImportCheckpointManager(this).canUndo()) { + statusText.text = getString(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() + } + + private fun undoLastCsvImport() { + statusText.text = getString(R.string.csv_import_undoing) + fileIoExecutor.execute { + val succeeded = runCatching { CsvImportCheckpointManager(this).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, + ) + } + } + } @Suppress("DEPRECATION") private fun importPlatoonBackup() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) @@ -656,7 +838,8 @@ class MainActivity : LocalizedActivity() { const val REQUEST_BACKUP_EXPORT = 103 const val REQUEST_BACKUP_IMPORT = 104 const val REQUEST_CSV_IMPORT = 105 - const val MAX_CSV_IMPORT_FILES = 512 + const val MAX_CSV_IMPORT_FILES = 64 + const val MAX_CSV_IMPORT_BYTES = 16L * 1024 * 1024 const val STATUS_REFRESH_MILLIS = 1_000L val BACKUP_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") val BACKUP_MIME_TYPES = arrayOf( @@ -671,4 +854,10 @@ class MainActivity : LocalizedActivity() { val duplicates: Int, val imported: PlatoonRepository.ImportResult, ) + + private data class PendingCsvImport( + val prepared: List, + val duplicateFileNames: Set, + val preview: CsvImportPreviewAnalyzer.Preview, + ) } diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 4151ed5..2250b59 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -7,18 +7,22 @@ import android.database.Cursor import android.graphics.Color import android.graphics.Typeface import android.net.Uri +import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.OpenableColumns +import android.text.InputType import android.view.ViewGroup import android.widget.Button import android.widget.CheckBox +import android.widget.EditText import android.widget.LinearLayout import android.widget.RadioButton import android.widget.RadioGroup 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.capture.CaptureDiagnosticsStore @@ -26,6 +30,7 @@ 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.discord.DiscordWebhookSecretStore import dev.gf2log.protocol.Gfl2PayloadDecoder import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -145,6 +150,96 @@ class OptionsActivity : LocalizedActivity() { setPadding(dp(16), 0, 0, spacing) }, matchWidth()) + addView(TextView(context).apply { + text = getString(R.string.discord_webhook) + textSize = 20f + 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 = 14f + 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 + } + val borderColor = if (webhookConfigured) { + R.color.webhook_configured_border + } else { + R.color.webhook_missing_border + } + setTextColor(context.getColor(foregroundColor)) + background = GradientDrawable().apply { + setColor(context.getColor(backgroundColor)) + cornerRadius = dp(10).toFloat() + setStroke(dp(1), context.getColor(borderColor)) + } + setPadding(dp(12), dp(10), dp(12), dp(10)) + }, matchWidth()) + 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, matchWidth()) + addView(Button(context).apply { + text = getString(R.string.save_discord_webhook) + setOnClickListener { + val saved = runCatching { + webhookStore.save(webhookInput.text.toString()) + }.isSuccess + Toast.makeText( + this@OptionsActivity, + if (saved) { + R.string.discord_webhook_saved + } else { + R.string.discord_webhook_invalid + }, + Toast.LENGTH_LONG, + ).show() + if (saved) recreate() + } + }, matchWidth()) + addView(Button(context).apply { + text = getString(R.string.clear_discord_webhook) + isEnabled = webhookConfigured + setOnClickListener { + val cleared = runCatching(webhookStore::clear).isSuccess + Toast.makeText( + this@OptionsActivity, + if (cleared) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, + Toast.LENGTH_LONG, + ).show() + if (cleared) recreate() + } + }, matchWidth()) + addView(TextView(context).apply { text = getString(R.string.payload_history) textSize = 20f diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index ab02cb2..657e7eb 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -1,5 +1,8 @@ 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.Color @@ -7,8 +10,10 @@ 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 @@ -18,16 +23,25 @@ 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 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 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) @@ -62,6 +76,43 @@ class PacketHistoryActivity : LocalizedActivity() { } } addView(actionButton, matchWidth()) + 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(8) + setPaddingRelative( + paddingStart, paddingTop, paddingEnd + dp(32), paddingBottom, + ) + setOnClickListener { confirmSendOriginalCsv() } + } + addView(FrameLayout(context).apply { + addView( + sendButton, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + 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, + ), + ) + }, matchWidth()) contentContainer = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL setPadding(0, spacing, 0, 0) @@ -71,6 +122,86 @@ class PacketHistoryActivity : LocalizedActivity() { 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 onDestroy() { + sendExecutor.shutdownNow() + super.onDestroy() + } + + private fun confirmSendOriginalCsv() { + val csv = runCatching { OriginalCsvPayload.extract(rawContent) }.getOrElse { + Toast.makeText(this, R.string.discord_csv_invalid, Toast.LENGTH_LONG).show() + 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) + } + .show() + } + + // 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 + Toast.makeText(this, R.string.discord_csv_sending, Toast.LENGTH_SHORT).show() + sendExecutor.execute { + val sent = runCatching { DiscordWebhookSender().send(webhook, csv) }.isSuccess + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + refreshSendAvailability() + Toast.makeText( + this, + if (sent) R.string.discord_csv_sent else R.string.discord_csv_send_failed, + Toast.LENGTH_LONG, + ).show() + } + } } private fun showTable() { @@ -128,6 +259,8 @@ class PacketHistoryActivity : LocalizedActivity() { Toast.makeText(this, getString(R.string.status_packet_copied), Toast.LENGTH_SHORT).show() } + 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, diff --git a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt b/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt index 4c5c789..4d86b95 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt @@ -10,3 +10,10 @@ fun Button.usePrimaryActionStyle() { setTypeface(typeface, Typeface.BOLD) minHeight = (48 * resources.displayMetrics.density).toInt() } + +fun Button.useDestructiveActionStyle() { + backgroundTintList = ColorStateList.valueOf(context.getColor(R.color.destructive_action_background)) + setTextColor(context.getColor(R.color.destructive_action_foreground)) + setTypeface(typeface, Typeface.BOLD) + minHeight = (48 * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index ce93f90..b9ecff7 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -19,7 +19,9 @@ import android.view.Gravity 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 @@ -27,6 +29,9 @@ import android.widget.ScrollView import android.widget.Spinner import android.widget.TextView import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.PlatoonPeriods import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.MemberEvent @@ -36,14 +41,19 @@ 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.WeeklyEvidenceAnalyzer import dev.gf2log.app.management.WeeklyNote 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.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines 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 @@ -52,20 +62,24 @@ import java.util.concurrent.Executors class WeeklyReportActivity : LocalizedActivity() { private lateinit var repository: PlatoonRepository private lateinit var body: LinearLayout - private var referenceDay: LocalDate = - PlatoonPeriods.gameDay(Instant.now(), ZoneId.systemDefault()) + 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 var editingPeriodStart: LocalDate? = null private val editDraft = mutableMapOf() - private var renderGeneration = 0 - private var screenResumed = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) repository = PlatoonRepository(this) + reportState = WeeklyReportStateHolder( + savedInstanceState?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } + ?.getLong(STATE_REFERENCE_DAY) + ?.let(LocalDate::ofEpochDay) + ?: PlatoonPeriods.gameDay(Instant.now(), ZoneId.systemDefault()), + ) body = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(16), dp(16), dp(16), dp(16)) @@ -75,15 +89,19 @@ class WeeklyReportActivity : LocalizedActivity() { override fun onResume() { super.onResume() - screenResumed = true + reportState.onResume() requestRender(reconcileRetainedCsv = true) } override fun onPause() { - screenResumed = false - renderGeneration += 1 + reportState.onPause() super.onPause() } + override fun onSaveInstanceState(outState: Bundle) { + outState.putLong(STATE_REFERENCE_DAY, reportState.referenceDay.toEpochDay()) + super.onSaveInstanceState(outState) + } + override fun onDestroy() { workerExecutor.shutdownNow() @@ -99,18 +117,17 @@ class WeeklyReportActivity : LocalizedActivity() { // Returns: // - Returns immediately after scheduling the load. private fun requestRender(reconcileRetainedCsv: Boolean = false) { - val targetDay = referenceDay - val generation = ++renderGeneration + val request = reportState.newRenderRequest() showLoading() workerExecutor.execute { val result = runCatching { if (reconcileRetainedCsv) repository.reconcileRetainedCsvFiles() - loadRenderModel(targetDay) + loadRenderModel(request.referenceDay) } runOnUiThread { - if (!canApplyRender(generation)) return@runOnUiThread + if (!canApplyRender(request.generation)) return@runOnUiThread result.fold( - onSuccess = { render(it, generation) }, + onSuccess = { render(it) }, onFailure = { showLoadFailure() }, ) } @@ -131,6 +148,7 @@ class WeeklyReportActivity : LocalizedActivity() { val membershipStartInstant = periodStart.atStartOfDay(zone).toInstant() val membershipEndInstant = periodStart.plusDays(7).atStartOfDay(zone).toInstant() val report = repository.buildWeeklyReport(targetDay, zone) + val memberStatuses = repository.listMemberStatuses() return RenderModel( zone = zone, report = report, @@ -145,15 +163,16 @@ class WeeklyReportActivity : LocalizedActivity() { it.type in MembershipEventPresentation.displayedTypes && it.source in MembershipEventPresentation.displayedSources }, - namesByUid = repository.listMemberStatuses().associate { it.uid to it.name }, + namesByUid = memberStatuses.associate { it.uid to it.name }, cutlines = WeeklyCutlinePreferences(this).read(), + memberNotesByUid = memberStatuses.associate { it.uid to it.note }, displayedMembers = MemberOrderPreferences(this).apply(report.members) { it.uid }, scoreRanks = report.members.withIndex().associate { it.value.uid to it.index + 1 }, ) } private fun canApplyRender(generation: Int): Boolean = - generation == renderGeneration && screenResumed && !isFinishing && !isDestroyed + reportState.canApply(generation) && !isFinishing && !isDestroyed private fun showLoading() { body.removeAllViews() @@ -180,13 +199,12 @@ class WeeklyReportActivity : LocalizedActivity() { // Function Name: render // Description: // - Builds the weekly screen from an already-loaded immutable model. - // - Delegates table rows to frame-sized batches so input remains responsive. + // - Delegates member rows to a bounded RecyclerView viewport. // Parameters: // - model: Repository and preference state for one reporting week. - // - generation: Token used to cancel stale row batches. // Returns: - // - Returns after building the screen shell and scheduling table rows. - private fun render(model: RenderModel, generation: Int) { + // - Unit after building the current screen projection. + private fun render(model: RenderModel) { body.removeAllViews() val zone = model.zone val report = model.report @@ -217,6 +235,17 @@ class WeeklyReportActivity : LocalizedActivity() { } } }, LinearLayout.LayoutParams(dp(48), dp(48))) + addView(ImageButton(context).apply { + setImageResource(R.drawable.ic_share) + setBackgroundColor(Color.TRANSPARENT) + 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))) addView(ImageButton(context).apply { setImageResource(R.drawable.ic_settings) setBackgroundColor(Color.TRANSPARENT) @@ -253,6 +282,7 @@ class WeeklyReportActivity : LocalizedActivity() { setPadding(0, dp(8), 0, dp(8)) }, matchWidth()) } + addEvidenceHealthPanel(report) body.addView(LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL addView(Button(context).apply { @@ -260,7 +290,7 @@ class WeeklyReportActivity : LocalizedActivity() { isEnabled = !isEditing contentDescription = getString(R.string.previous_week) setOnClickListener { - referenceDay = report.periodStart.minusDays(1) + reportState.showPreviousWeek(report.periodStart) requestRender() } }, LinearLayout.LayoutParams(0, wrap(), 1f)) @@ -274,7 +304,7 @@ class WeeklyReportActivity : LocalizedActivity() { isEnabled = !isEditing contentDescription = getString(R.string.next_week) setOnClickListener { - referenceDay = report.periodEnd.plusDays(1) + reportState.showNextWeek(report.periodEnd) requestRender() } }, LinearLayout.LayoutParams(0, wrap(), 1f)) @@ -311,7 +341,7 @@ class WeeklyReportActivity : LocalizedActivity() { setPadding(0, dp(16), 0, dp(16)) }, matchWidth()) } else { - body.addView(buildTable(model, isEditing, generation), matchWidth()) + body.addView(buildVirtualizedTable(model, isEditing), matchWidth()) } if (isEditing) return @@ -336,62 +366,100 @@ class WeeklyReportActivity : LocalizedActivity() { addNoteEditor(report) } - private fun buildTable( + + + // 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, - generation: Int, ) = 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(model.report), wrapWidth()) + 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) - appendMemberRows(rows, model, isEditing, generation) } - // Function Name: appendMemberRows - // Description: - // - Creates only a small number of expensive nested table rows per display frame. - // - Stops immediately when navigation or lifecycle changes invalidate this render. - // Parameters: - // - rows: Vertical table container receiving rendered member rows. - // - model: Immutable data and display ordering for the selected week. - // - isEditing: Whether rows contain editable fields. - // - generation: Token identifying the active render request. - // Returns: - // - Returns after scheduling the first batch. - private fun appendMemberRows( - rows: LinearLayout, - model: RenderModel, - isEditing: Boolean, - generation: Int, - ) { - var nextIndex = 0 - val appendBatch = object : Runnable { - override fun run() { - if (!canApplyRender(generation)) return - val until = minOf(nextIndex + TABLE_ROW_BATCH_SIZE, model.displayedMembers.size) - while (nextIndex < until) { - val member = model.displayedMembers[nextIndex++] - rows.addView( - memberRow( - member = member, - rank = model.scoreRanks[member.uid], - gunsmokeWeek = model.report.isGunsmokeWeek, - cutlines = model.cutlines, - isEditing = isEditing, - zoneId = model.zone, - ), - wrapWidth(), - ) - } - if (nextIndex < model.displayedMembers.size) rows.postOnAnimation(this) - } + 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), + ) + } + + override fun onViewRecycled(holder: WeeklyMemberViewHolder) { + holder.container.removeAllViews() + super.onViewRecycled(holder) + } - rows.postOnAnimation(appendBatch) } + private class WeeklyMemberViewHolder( + val container: FrameLayout, + ) : RecyclerView.ViewHolder(container) + private fun headerRow(report: WeeklyReportBuilder.Report) = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL isBaselineAligned = false @@ -426,6 +494,7 @@ class WeeklyReportActivity : LocalizedActivity() { editableDailyMetricGroup(member.uid, cell, gunsmokeWeek) } else { dailyMetricGroup( + memberName = member.name, cell = cell, gunsmokeWeek = gunsmokeWeek, dayClosed = !PlatoonPeriods.periodStartInstant( @@ -437,7 +506,7 @@ class WeeklyReportActivity : LocalizedActivity() { }, ) } - addView(totalMetricGroup(member, gunsmokeWeek, cutlines)) + addView(totalMetricGroup(member.name, member, gunsmokeWeek, cutlines)) } private fun editableDailyMetricGroup( @@ -595,12 +664,17 @@ class WeeklyReportActivity : LocalizedActivity() { } 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), @@ -661,11 +735,16 @@ class WeeklyReportActivity : LocalizedActivity() { } 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), @@ -727,6 +806,8 @@ class WeeklyReportActivity : LocalizedActivity() { } private fun metricGroup( + memberName: String, + explanations: Map, showGunsmokeMetrics: Boolean, merit: CharSequence, score: CharSequence, @@ -738,7 +819,18 @@ class WeeklyReportActivity : LocalizedActivity() { val groupHeight = metricGroupHeight(showGunsmokeMetrics) layoutParams = LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(groupHeight)) addView( - gridCell(merit, DAILY_WIDTH, METRIC_HEIGHT, textSize = 11f), + gridCell( + merit, + DAILY_WIDTH, + METRIC_HEIGHT, + textSize = 11f, + onClick = { + showEvidenceExplanation( + memberName, + requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.MERIT]), + ) + }, + ), LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT)), ) if (showGunsmokeMetrics) { @@ -746,11 +838,33 @@ class WeeklyReportActivity : LocalizedActivity() { orientation = LinearLayout.HORIZONTAL isBaselineAligned = false addView( - gridCell(score, DAILY_WIDTH / 2, METRIC_HEIGHT, textSize = 10f), + 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( - gridCell(attempts, DAILY_WIDTH / 2, METRIC_HEIGHT, textSize = 10f), + 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))) @@ -759,11 +873,33 @@ class WeeklyReportActivity : LocalizedActivity() { orientation = LinearLayout.HORIZONTAL isBaselineAligned = false addView( - gridCell(login, DAILY_WIDTH / 2, METRIC_HEIGHT, textSize = 10f), + 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), + 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))) @@ -778,6 +914,7 @@ class WeeklyReportActivity : LocalizedActivity() { height: Int, header: Boolean = false, textSize: Float = if (header) 12f else 11f, + onClick: (() -> Unit)? = null, ) = TextView(this).apply { text = value gravity = Gravity.CENTER @@ -792,6 +929,103 @@ class WeeklyReportActivity : LocalizedActivity() { setStroke(1, GRID_COLOR) } 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) + body.addView(TextView(this).apply { + text = buildString { + append(getString(R.string.evidence_health_title)) + append("\n") + append( + getString( + R.string.evidence_health_summary, + health.observedDays, + health.totalDays, + health.exactMetrics, + health.lowerBoundMetrics, + health.unknownMetrics, + health.directLoginDays, + health.directPatrolDays, + health.closingBoundaries, + ), + ) + } + textSize = 14f + setTextColor(if (health.isComplete) Color.rgb(35, 105, 62) else WARNING_COLOR) + setPadding(dp(12), dp(10), dp(12), dp(10)) + background = GradientDrawable().apply { + setColor(if (health.isComplete) Color.rgb(231, 246, 236) else Color.rgb(255, 247, 222)) + cornerRadius = dp(8).toFloat() + } + }, matchWidth()) + } + + 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, + ), + ) + .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 { @@ -938,12 +1172,12 @@ class WeeklyReportActivity : LocalizedActivity() { DatePickerDialog( this, { _, year, month, day -> - referenceDay = LocalDate.of(year, month + 1, day) + reportState.selectDate(LocalDate.of(year, month + 1, day)) requestRender() }, - referenceDay.year, - referenceDay.monthValue - 1, - referenceDay.dayOfMonth, + reportState.referenceDay.year, + reportState.referenceDay.monthValue - 1, + reportState.referenceDay.dayOfMonth, ).show() } @@ -1072,10 +1306,163 @@ class WeeklyReportActivity : LocalizedActivity() { .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()) + } + 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, + ) { + Toast.makeText(this, R.string.weekly_png_rendering, Toast.LENGTH_SHORT).show() + 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) + }, + onFailure = { + Toast.makeText( + this, + R.string.weekly_png_failed, + Toast.LENGTH_LONG, + ).show() + }, + ) + } + } + } + + private fun writeWeeklyPng( + document: WeeklyShareProjection.Document, + periodStart: LocalDate, + ): File { + val directory = File(cacheDir, "shared-weekly").apply { mkdirs() } + require(directory.isDirectory) { "Unable to create weekly share cache" } + directory.listFiles().orEmpty().forEach(File::delete) + val target = File( + directory, + "GF2logger-week-" + periodStart.format(FILE_DATE) + ".png", + ) + 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" + } + output.fd.sync() + } + check(temporary.renameTo(target)) { "Unable to publish weekly PNG" } + } finally { + bitmap.recycle() + temporary.delete() + } + return target + } + + 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))) + } + + @Suppress("DEPRECATION") + private fun saveWeeklyPng(file: File) { + pendingPng = file + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("image/png") + .putExtra(Intent.EXTRA_TITLE, file.name) + 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() + Toast.makeText( + this, + getString( + if (exported) R.string.weekly_png_saved else R.string.weekly_png_failed, + ), + Toast.LENGTH_SHORT, + ).show() + return + } + if (requestCode != REQUEST_EXPORT_WEEKLY) return + val content = pendingCsv pendingCsv = null val destination = data?.data @@ -1186,6 +1573,7 @@ class WeeklyReportActivity : LocalizedActivity() { val cutlines: WeeklyCutlines, val displayedMembers: List, val scoreRanks: Map, + val memberNotesByUid: Map, ) private data class ActivityMark(val symbol: String, val color: Int?) @@ -1231,12 +1619,14 @@ class WeeklyReportActivity : LocalizedActivity() { 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 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 TABLE_ROW_BATCH_SIZE = 2 + private const val MAX_VISIBLE_TABLE_ROWS = 6 private val GRID_COLOR = Color.rgb(112, 118, 128) private val EDITABLE_FIELD_COLOR = Color.rgb(47, 58, 72) private val EDITABLE_FIELD_BORDER_COLOR = Color.rgb(126, 164, 218) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt new file mode 100644 index 0000000..6491759 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt @@ -0,0 +1,168 @@ +package dev.gf2log.app + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +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. */ +object WeeklyReportPngRenderer { + fun render(document: WeeklyShareProjection.Document): Bitmap { + require(document.headers.size in 3..11) + require(document.rows.size <= MAX_ROWS) + val memberWidth = 240 + val dayWidth = 156 + val totalWidth = 168 + val notesWidth = if (document.includeNotes) 300 else 0 + val days = document.headers.size - 2 - if (document.includeNotes) 1 else 0 + val naturalWidth = PADDING * 2 + memberWidth + dayWidth * days + totalWidth + notesWidth + val rowHeight = if (document.rows.any { row -> row.dailyCells.any { it.count { c -> c == '\n' } >= 4 } }) { + 112 + } else { + 88 + } + val naturalHeight = PADDING * 2 + TITLE_HEIGHT + HEADER_HEIGHT + rowHeight * document.rows.size + val naturalPixels = naturalWidth.toLong() * naturalHeight + val scale = min( + 1f, + min( + MAX_DIMENSION.toFloat() / maxOf(naturalWidth, naturalHeight), + sqrt(MAX_PIXELS.toDouble() / naturalPixels.coerceAtLeast(1)).toFloat(), + ), + ) + val width = maxOf(1, (naturalWidth * scale).toInt()) + 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) + canvas.scale(scale, scale) + + val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(27, 47, 78) + textSize = 30f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + val subtitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(70, 78, 91) + 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, + PADDING.toFloat(), + (PADDING + 84).toFloat(), + subtitlePaint, + ) + + var top = PADDING + TITLE_HEIGHT + var left = PADDING + drawCell(canvas, document.headers.first(), left, top, memberWidth, HEADER_HEIGHT, header = true) + left += memberWidth + document.headers.drop(1).forEachIndexed { index, value -> + val isLast = index == document.headers.lastIndex - 1 + val cellWidth = when { + isLast && document.includeNotes -> notesWidth + index == days -> totalWidth + else -> dayWidth + } + drawCell(canvas, value, left, top, cellWidth, HEADER_HEIGHT, header = true) + left += cellWidth + } + + top += HEADER_HEIGHT + document.rows.forEach { row -> + left = PADDING + drawCell(canvas, row.member, left, top, memberWidth, rowHeight) + left += memberWidth + row.dailyCells.forEach { value -> + drawCell(canvas, value, left, top, dayWidth, rowHeight) + left += dayWidth + } + drawCell(canvas, row.total, left, top, totalWidth, rowHeight) + left += totalWidth + if (document.includeNotes) { + drawCell(canvas, row.privateNote.orEmpty(), left, top, notesWidth, rowHeight) + } + top += rowHeight + } + return bitmap + } + + private fun drawCell( + canvas: Canvas, + value: String, + left: Int, + top: Int, + width: Int, + height: Int, + 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 lines = wrap(value, textPaint, width - CELL_PADDING * 2) + .take(MAX_CELL_LINES) + val lineHeight = textPaint.fontSpacing + var baseline = top + CELL_PADDING - textPaint.fontMetrics.top + lines.forEach { line -> + if (baseline <= top + height - CELL_PADDING) { + canvas.drawText(line, (left + CELL_PADDING).toFloat(), baseline, textPaint) + } + baseline += lineHeight + } + } + + private fun wrap(value: String, paint: Paint, maxWidth: Int): List = buildList { + value.lineSequence().forEach { source -> + var remaining = source.trim() + if (remaining.isEmpty()) { + add("") + return@forEach + } + while (remaining.isNotEmpty()) { + val count = paint.breakText(remaining, true, maxWidth.toFloat(), null) + .coerceAtLeast(1) + var split = count + if (count < remaining.length) { + remaining.lastIndexOf(' ', count - 1).takeIf { it > 0 }?.let { split = it } + } + add(remaining.take(split).trim()) + remaining = remaining.drop(split).trimStart() + } + } + } + + private const val PADDING = 28 + private const val TITLE_HEIGHT = 104 + private const val HEADER_HEIGHT = 52 + private const val CELL_PADDING = 9 + private const val MAX_CELL_LINES = 6 + private const val MAX_ROWS = 256 + private const val MAX_DIMENSION = 16_000 + private const val MAX_PIXELS = 12_000_000 +} 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 651e227..16dc0a2 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -10,19 +10,42 @@ object CaptureStatus { var isRunning: Boolean = false private set + @Volatile + private var guidedProgress: GuidedProgress? = null + fun read(): String = message + fun readGuidedProgress(): GuidedProgress? = guidedProgress + fun update(message: String) { this.message = message } - fun markRunning(message: String) { + fun markRunning(message: String, guided: Boolean = guidedProgress != null) { isRunning = true this.message = message + if (guided && guidedProgress == null) guidedProgress = GuidedProgress() } fun markStopped(message: String = DEFAULT_MESSAGE) { isRunning = false this.message = message } + + fun beginSession(guided: Boolean) { + guidedProgress = GuidedProgress().takeIf { guided } + } + + fun markUsefulPayload(payloadType: Int) { + val current = guidedProgress ?: return + guidedProgress = current.copy(capturedPayloadTypes = current.capturedPayloadTypes + payloadType) + } + + fun clearSession() { + guidedProgress = null + } + + data class GuidedProgress( + val capturedPayloadTypes: Set = emptySet(), + ) } 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 374f28c..9c23a95 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -130,6 +130,7 @@ class CaptureVpnService : VpnService() { ACTION_STOP -> stopCapture() ACTION_START -> { captureOnce = intent.getBooleanExtra(EXTRA_CAPTURE_ONCE, false) + CaptureStatus.beginSession(captureOnce) startCapture(intent.getStringExtra(EXTRA_TARGET_PACKAGE).orEmpty()) } } @@ -329,19 +330,13 @@ class CaptureVpnService : VpnService() { } val routed = platoonPayloadDispatcher.dispatch(event.value, flowEnded) routed.activity - ?.onSuccess { accepted -> - if (accepted) { - capturedRequiredTypes += Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY - maybeStopCaptureOnce() - } + ?.onSuccess { + markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) } ?.onFailure { CaptureStatus.update("Unable to update Platoon activity history") } routed.updates - ?.onSuccess { accepted -> - if (accepted) { - capturedRequiredTypes += Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES - maybeStopCaptureOnce() - } + ?.onSuccess { + markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) } ?.onFailure { CaptureStatus.update("Unable to update exact Platoon history") } routed.members @@ -372,11 +367,16 @@ class CaptureVpnService : VpnService() { } private fun markRosterCaptured() { - capturedRequiredTypes += Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS + markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) if (captureOnce) { mainHandler.removeCallbacks(captureOnceGraceStop) mainHandler.postDelayed(captureOnceGraceStop, CAPTURE_ONCE_GRACE_MILLIS) } + } + + private fun markRequiredPayloadCaptured(payloadType: Int) { + capturedRequiredTypes += payloadType + CaptureStatus.markUsefulPayload(payloadType) maybeStopCaptureOnce() } diff --git a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt new file mode 100644 index 0000000..2fbf1e3 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt @@ -0,0 +1,27 @@ +package dev.gf2log.app.discord + +import java.net.URI + +/** Strictly limits stored credentials to Discord's incoming-webhook execution endpoint. */ +object DiscordWebhookPolicy { + fun requireValid(value: String): URI { + val normalized = value.trim() + require(normalized.length in 1..MAX_URL_CHARS) { "Invalid Discord webhook URL" } + val uri = URI(normalized) + require(uri.scheme.equals("https", ignoreCase = true)) { "Discord webhook must use HTTPS" } + require(uri.host.equals("discord.com", ignoreCase = true)) { + "Discord webhook host must be discord.com" + } + require(uri.port == -1 || uri.port == 443) { "Discord webhook must use the HTTPS port" } + 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" } + return uri + } + + private const val MAX_URL_CHARS = 512 + private val WEBHOOK_PATH = Regex( + "^/api(?:/v\\d{1,2})?/webhooks/\\d{17,20}/[A-Za-z0-9._-]{32,200}$", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt new file mode 100644 index 0000000..04d2e19 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt @@ -0,0 +1,93 @@ +package dev.gf2log.app.discord + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Stores a user-provided Discord webhook token encrypted by a non-exportable Android Keystore key. */ +class DiscordWebhookSecretStore(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences( + PREFERENCES, + Context.MODE_PRIVATE, + ) + + fun save(value: String) { + val canonical = DiscordWebhookPolicy.requireValid(value).toASCIIString() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val encrypted = cipher.doFinal(canonical.toByteArray(Charsets.UTF_8)) + check( + preferences.edit() + .putString(CIPHERTEXT, Base64.encodeToString(encrypted, Base64.NO_WRAP)) + .putString(IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .commit(), + ) { "Unable to store the encrypted Discord webhook" } + } + + fun read(): String? { + val ciphertext = preferences.getString(CIPHERTEXT, null) ?: return null + val iv = preferences.getString(IV, null) ?: return null + return runCatching { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + requireKey(), + GCMParameterSpec(GCM_TAG_BITS, Base64.decode(iv, Base64.NO_WRAP)), + ) + val decoded = cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)) + .toString(Charsets.UTF_8) + DiscordWebhookPolicy.requireValid(decoded).toASCIIString() + }.getOrElse { + clear() + null + } + } + + fun clear() { + check(preferences.edit().clear().commit()) { + "Unable to clear the encrypted Discord webhook" + } + } + + private fun getOrCreateKey(): SecretKey = keyStore().let { store -> + (store.getKey(KEY_ALIAS, null) as? SecretKey) ?: KeyGenerator + .getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + .apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build(), + ) + } + .generateKey() + } + + private fun requireKey(): SecretKey = requireNotNull( + keyStore().getKey(KEY_ALIAS, null) as? SecretKey, + ) { "Discord webhook encryption key is unavailable" } + + private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + + private companion object { + const val PREFERENCES = "discord_webhook_secret" + const val CIPHERTEXT = "ciphertext" + const val IV = "iv" + const val KEY_ALIAS = "gf2logger.discord.webhook.v1" + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_TAG_BITS = 128 + } +} diff --git a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSender.kt b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSender.kt new file mode 100644 index 0000000..8dff52b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSender.kt @@ -0,0 +1,74 @@ +package dev.gf2log.app.discord + +import java.net.HttpURLConnection +import java.util.UUID +import javax.net.ssl.HttpsURLConnection + +/** Sends one explicitly confirmed original CSV file to a validated Discord incoming webhook. */ +class DiscordWebhookSender { + fun send(webhook: String, csv: String) { + val uri = DiscordWebhookPolicy.requireValid(webhook) + val csvBytes = csv.toByteArray(Charsets.UTF_8) + require(csvBytes.isNotEmpty() && csvBytes.size <= OriginalCsvPayload.MAX_CSV_BYTES) + val boundary = "----GF2logger" + UUID.randomUUID().toString().replace("-", "") + val prefix = ( + "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"files[0]\"; " + + "filename=\"GF2logger-original.csv\"\r\n" + + "Content-Type: text/csv; charset=utf-8\r\n\r\n" + ).toByteArray(Charsets.UTF_8) + val suffix = ("\r\n--" + boundary + "--\r\n").toByteArray(Charsets.UTF_8) + val contentLength = prefix.size.toLong() + csvBytes.size + suffix.size + require(contentLength <= MAX_REQUEST_BYTES) + + val connection = uri.toURL().openConnection() as HttpsURLConnection + try { + connection.requestMethod = "POST" + connection.instanceFollowRedirects = false + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + connection.doOutput = true + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary) + connection.setRequestProperty("User-Agent", "GF2logger-Android") + connection.setFixedLengthStreamingMode(contentLength) + connection.outputStream.use { output -> + output.write(prefix) + output.write(csvBytes) + output.write(suffix) + output.flush() + } + val status = connection.responseCode + consumeBounded( + if (status >= HttpURLConnection.HTTP_BAD_REQUEST) { + connection.errorStream + } else { + connection.inputStream + }, + ) + check(status in 200..299) { "Discord webhook returned HTTP " + status } + } finally { + connection.disconnect() + } + } + + private fun consumeBounded(input: java.io.InputStream?) { + if (input == null) return + input.use { stream -> + val buffer = ByteArray(1024) + var total = 0 + while (true) { + val count = stream.read(buffer) + if (count < 0) break + total += count + if (total > MAX_RESPONSE_BYTES) break + } + } + } + + private companion object { + const val CONNECT_TIMEOUT_MILLIS = 10_000 + const val READ_TIMEOUT_MILLIS = 15_000 + const val MAX_RESPONSE_BYTES = 8 * 1024 + const val MAX_REQUEST_BYTES = OriginalCsvPayload.MAX_CSV_BYTES + 4 * 1024L + } +} diff --git a/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt b/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt new file mode 100644 index 0000000..4e3d21b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt @@ -0,0 +1,25 @@ +package dev.gf2log.app.discord + +import dev.gf2log.protocol.ParsedPacketTableParser + +/** Extracts only the validated CSV body from a parsed-packet history envelope. */ +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) { + "Parsed packet does not contain an original CSV body" + } + requireNotNull(ParsedPacketTableParser.parse(normalized)) { + "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" + } + } + } + + const val MAX_CSV_BYTES = 512 * 1024 +} 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 851d583..7fc219c 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -6,6 +6,7 @@ import java.io.BufferedOutputStream import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File +import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream import java.security.MessageDigest @@ -26,7 +27,7 @@ internal object BackupArchive { } else { BackupFormatPolicy.COMPLETE_VERSION } - ZipOutputStream(BufferedOutputStream(output)).use { zip -> + ZipOutputStream(BufferedOutputStream(NonClosingOutputStream(output))).use { zip -> zip.putNextEntry(ZipEntry(MANIFEST_ENTRY)) Properties().apply { setProperty(KEY_FORMAT_VERSION, formatVersion.toString()) @@ -175,6 +176,11 @@ internal object BackupArchive { 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() + } + private const val MANIFEST_ENTRY = "manifest.properties" private const val DATABASE_ENTRY = "platoon.db" private const val SETTINGS_ENTRY = "settings.properties" diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt new file mode 100644 index 0000000..90bd248 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -0,0 +1,305 @@ +package dev.gf2log.app.management + +import android.content.Context +import dev.gf2log.app.settings.AppSettingsStore +import java.io.File +import java.io.FileOutputStream + +/** + * Owns the one-level automatic checkpoint used to undo the most recent CSV import. + * + * The database archive and planned deterministic retained-file names are published together. + * A provisional import preserves its predecessor until sealing. Restore progress is journaled so + * quarantined evidence and the database converge after ordinary failures or process termination. + */ +class CsvImportCheckpointManager internal constructor( + context: Context, + private val restoreObserver: (PlatoonBackupManager.RestoreCheckpoint) -> Unit, +) { + constructor(context: Context) : this(context, {}) + + private val appContext = context.applicationContext + private val root = File(appContext.filesDir, CHECKPOINT_DIRECTORY) + private val staging = File(appContext.filesDir, STAGING_DIRECTORY) + private val previous = File(appContext.filesDir, PREVIOUS_DIRECTORY) + + init { + synchronized(STATE_LOCK) { + if (!operationInProgress) recoverInterruptedState() + } + } + + fun canUndo(): Boolean = synchronized(STATE_LOCK) { + !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. + fun create(plannedFileNames: Set) { + claimOperation() + var published = false + try { + val names = plannedFileNames.toSortedSet() + require(names.size <= MAX_PLANNED_FILES) { "Too many CSV files for one checkpoint" } + names.forEach(::requireSafeImportName) + check(!File(root, QUARANTINE_DIRECTORY).exists()) { + "A previous CSV import undo must be retried before another import" + } + deleteDirectory(staging) + check(staging.mkdirs()) { "Unable to stage the CSV import checkpoint" } + FileOutputStream(archive(staging)).use { output -> + PlatoonBackupManager(appContext).export(output) + output.fd.sync() + } + FileOutputStream(manifest(staging)).use { output -> + output.write(names.joinToString("\n", postfix = "\n").toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + check(!previous.exists()) { "A previous CSV import checkpoint is still pending" } + if (root.exists() && !root.renameTo(previous)) { + error("Unable to preserve the previous CSV import checkpoint") + } + if (!staging.renameTo(root)) { + if (previous.exists() && !previous.renameTo(root)) { + error("Unable to publish the CSV import checkpoint or restore its predecessor") + } + error("Unable to publish the CSV import checkpoint") + } + deleteDirectory(staging) + published = true + } finally { + if (!published) { + runCatching { deleteDirectory(staging) } + releaseOperation() + } + } + } + + /** Seals a successful import so undo is refused after any later database mutation. */ + fun seal() { + check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } + val target = digest(root) + check(!target.exists()) { "CSV import checkpoint is already sealed" } + val temporary = File(root, "$DIGEST_FILE.tmp") + try { + FileOutputStream(temporary).use { output -> + output.write( + PlatoonBackupManager(appContext).currentDatabaseSha256() + .toByteArray(Charsets.US_ASCII), + ) + output.fd.sync() + } + check(temporary.renameTo(target)) { "Unable to seal the CSV import checkpoint" } + } finally { + temporary.delete() + } + deleteDirectory(previous) + releaseOperation() + } + + // 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. + fun restore() { + claimOperation() + try { + check(hasBaseCheckpoint() && digest(root).isFile) { + "No CSV import checkpoint is available" + } + val expectedDigest = digest(root).readText(Charsets.US_ASCII) + require(expectedDigest.matches(SHA256)) { "Invalid CSV checkpoint digest" } + check(PlatoonBackupManager(appContext).currentDatabaseSha256() == expectedDigest) { + "Platoon data changed after the CSV import; undo would overwrite newer changes" + } + restoreUnchecked() + } finally { + releaseOperation() + } + } + + internal fun rollbackFailedImport() { + try { + check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } + restoreUnchecked() + } finally { + releaseOperation() + } + } + + private fun restoreUnchecked() { + val names = manifest(root).readLines(Charsets.UTF_8) + .filter(String::isNotBlank) + .toSet() + require(names.size <= MAX_PLANNED_FILES) + names.forEach(::requireSafeImportName) + val retained = File(appContext.filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + val quarantine = File(root, QUARANTINE_DIRECTORY) + if (!quarantine.exists()) { + check(quarantine.mkdirs()) { "Unable to create the retained CSV quarantine" } + } + try { + names.forEach { name -> + val source = File(retained, name) + val target = File(quarantine, name) + if (target.exists()) { + check(!source.exists()) { + "Retained and quarantined CSV evidence both exist" + } + } else if (source.isFile) { + check(source.renameTo(target)) { "Unable to quarantine an imported CSV" } + } + } + val backupManager = PlatoonBackupManager( + context = appContext, + settingsStore = AppSettingsStore(appContext), + restoreObserver = { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + markRestoreDatabaseInstalled() + } + restoreObserver(checkpoint) + }, + ) + archive(root).inputStream().use(backupManager::restoreCheckpoint) + } catch (error: Exception) { + val marker = restoreMarker(root) + if (marker.exists() && !marker.delete()) { + error.addSuppressed( + IllegalStateException("Unable to clear the CSV undo restore marker"), + ) + } + restoreQuarantinedFiles(names, retained, quarantine, error) + throw error + } + deleteDirectory(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. + private fun recoverInterruptedState() { + deleteDirectory(staging) + if (!root.exists()) { + restorePreviousCheckpoint() + return + } + check(hasBaseCheckpoint()) { "CSV import checkpoint state is incomplete" } + val quarantine = File(root, QUARANTINE_DIRECTORY) + if (!digest(root).isFile || restoreMarker(root).isFile || quarantine.exists()) { + restoreUnchecked() + } else if (previous.exists()) { + deleteDirectory(previous) + } + } + + private fun restorePreviousCheckpoint() { + if (!previous.exists()) return + check(!root.exists()) { "Cannot restore a predecessor over a live CSV checkpoint" } + check(previous.renameTo(root)) { "Unable to restore the previous CSV import checkpoint" } + } + + private fun markRestoreDatabaseInstalled() { + val target = restoreMarker(root) + if (target.isFile) return + val temporary = File(root, "$RESTORE_MARKER.tmp") + try { + FileOutputStream(temporary).use { output -> + output.write("DATABASE_INSTALLED".toByteArray(Charsets.US_ASCII)) + output.fd.sync() + } + check(temporary.renameTo(target)) { + "Unable to publish the CSV undo restore marker" + } + } finally { + temporary.delete() + } + } + + private fun restoreQuarantinedFiles( + names: Set, + retained: File, + quarantine: File, + failure: Exception, + ) { + names.toList().asReversed().forEach { name -> + val target = File(quarantine, name) + if (!target.exists()) return@forEach + if (!retained.exists() && !retained.mkdirs()) { + failure.addSuppressed( + IllegalStateException("Unable to recreate retained CSV storage"), + ) + return@forEach + } + val source = File(retained, name) + if (source.exists() || !target.renameTo(source)) { + failure.addSuppressed( + IllegalStateException("Unable to restore retained CSV evidence"), + ) + } + } + } + + private fun claimOperation() { + synchronized(STATE_LOCK) { + check(!operationInProgress) { "Another CSV checkpoint operation is in progress" } + operationInProgress = true + } + } + + private fun releaseOperation() { + synchronized(STATE_LOCK) { + operationInProgress = false + } + } + + private fun requireSafeImportName(name: String) { + require(name.matches(SAFE_IMPORT_NAME)) { "Invalid CSV checkpoint identity" } + } + + private fun deleteDirectory(directory: File) { + if (directory.exists()) check(directory.deleteRecursively()) { + "Unable to remove CSV import checkpoint state" + } + } + + private fun archive(directory: File) = File(directory, ARCHIVE_FILE) + private fun manifest(directory: File) = File(directory, MANIFEST_FILE) + private fun digest(directory: File) = File(directory, DIGEST_FILE) + private fun restoreMarker(directory: File) = File(directory, RESTORE_MARKER) + + private companion object { + val STATE_LOCK = Any() + + @Volatile + var operationInProgress = false + + 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 QUARANTINE_DIRECTORY = "retained-quarantine" + const val ARCHIVE_FILE = "platoon.gf2backup" + const val MANIFEST_FILE = "planned-files.txt" + const val DIGEST_FILE = "post-import.sha256" + const val RESTORE_MARKER = "restore-database-installed" + const val MAX_PLANNED_FILES = 64 + val SAFE_IMPORT_NAME = Regex("import-\\d{8}T\\d{6}Z-[0-9a-f]{20}\\.csv") + val SHA256 = Regex("[0-9a-f]{64}") + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt new file mode 100644 index 0000000..cf30c33 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt @@ -0,0 +1,87 @@ +package dev.gf2log.app.management + +import java.time.Instant + +/** Computes a read-only impact summary for a validated CSV selection. */ +object CsvImportPreviewAnalyzer { + data class Preview( + val validatedFiles: Int, + val duplicateFiles: Int, + val historicalFiles: Int, + val uniqueMembers: Int, + val newMembers: Int, + val nameDifferences: Int, + val potentialJoins: Int, + val potentialWithdrawals: Int, + val totalBytes: Long, + val firstCapture: Instant?, + val lastCapture: Instant?, + ) + + /** Returns selected source identities already represented in the database. */ + fun duplicateFileNames( + prepared: List, + representedSourceFiles: Set, + ): Set { + val selectedNames = prepared.mapTo(mutableSetOf()) { it.fileName } + return selectedNames.intersect(representedSourceFiles) + } + + fun analyze( + prepared: List, + duplicateFileNames: Set, + existingMembers: List, + latestSnapshot: PlatoonSnapshot?, + ): Preview { + require(duplicateFileNames.all { name -> prepared.any { it.fileName == name } }) + val actionable = prepared + .filterNot { it.fileName in duplicateFileNames } + .sortedWith(compareBy(PlatoonCsvImportStore.PreparedImport::capturedAt, { it.fileName })) + val knownUids = existingMembers.mapTo(mutableSetOf()) { it.uid } + val importedUids = actionable + .flatMap { item -> item.snapshot.members.map { it.uid.toLong() } } + .toSet() + val importedNames = linkedMapOf() + actionable.forEach { item -> + item.snapshot.members.forEach { importedNames[it.uid.toLong()] = it.name } + } + val existingNames = existingMembers.associate { it.uid to it.name } + fun isHistorical(item: PlatoonCsvImportStore.PreparedImport): Boolean { + val baseline = latestSnapshot ?: return false + return item.capturedAt.isBefore(baseline.capturedAt) || + (item.capturedAt == baseline.capturedAt && + item.fileName <= baseline.sourceFile.orEmpty()) + } + val historicalFiles = actionable.count(::isHistorical) + + var roster = latestSnapshot?.members + ?.associate { it.uid to it.name } + ?: existingMembers.filter(MemberStatus::isActive).associate { it.uid to it.name } + var joins = 0 + var withdrawals = 0 + actionable + .filterNot(::isHistorical) + .forEach { item -> + val next = item.snapshot.members.associate { it.uid.toLong() to it.name } + joins += (next.keys - roster.keys).size + withdrawals += (roster.keys - next.keys).size + roster = next + } + + return Preview( + validatedFiles = prepared.size, + duplicateFiles = duplicateFileNames.size, + historicalFiles = historicalFiles, + uniqueMembers = importedUids.size, + newMembers = (importedUids - knownUids).size, + nameDifferences = importedNames.count { (uid, name) -> + existingNames[uid]?.let { it != name } == true + }, + potentialJoins = joins, + potentialWithdrawals = withdrawals, + totalBytes = prepared.sumOf { it.byteCount.toLong() }, + firstCapture = prepared.minOfOrNull { it.capturedAt }, + lastCapture = prepared.maxOfOrNull { it.capturedAt }, + ) + } +} 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 598c047..17f3702 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -12,6 +12,7 @@ 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( @@ -36,6 +37,23 @@ class PlatoonBackupManager internal constructor( } } + /** Returns a stable digest after closing SQLite so WAL state is checkpointed. */ + internal fun currentDatabaseSha256(): String = PlatoonRepository.withExclusiveDatabase { + 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) + } + } + fun exportFull(output: OutputStream) { PlatoonRepository(appContext).reconcileRetainedCsvFiles() PlatoonRepository.withExclusiveDatabase { @@ -68,6 +86,37 @@ class PlatoonBackupManager internal constructor( } } + // 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) { + val restoreDirectory = File(appContext.cacheDir, "platoon-restore").apply { 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 { + validateSelectedBackup { + BackupFormatPolicy.requirePlatoonOnly( + staged.formatVersion, + staged.settings != null, + ) + validateDatabase(stagedDatabase, requireCurrentSchema = true) + } + replaceRestoredState(stagedDatabase, restoredSettings = null, retireRetainedCsv = false) + } finally { + stagedDatabase.delete() + } + } + fun restoreFull(input: InputStream) { val restoreDirectory = File(appContext.cacheDir, "platoon-restore").apply { mkdirs() } val stagedDatabase = File(restoreDirectory, "platoon.db.staged") @@ -123,6 +172,7 @@ class PlatoonBackupManager internal constructor( private fun replaceRestoredState( stagedDatabase: File, restoredSettings: AppBackupSettings?, + retireRetainedCsv: Boolean = true, ) { val retainedCsvDirectory = File( appContext.filesDir, @@ -148,11 +198,13 @@ class PlatoonBackupManager internal constructor( settingsStore.replace(restoredSettings) restoreObserver(RestoreCheckpoint.SETTINGS_REPLACED) } - retireRetainedCsvCache( - retainedCsvDirectory, - previousRetainedCsvDirectory, - ) - restoreObserver(RestoreCheckpoint.RETAINED_CSV_RETIRED) + if (retireRetainedCsv) { + retireRetainedCsvCache( + retainedCsvDirectory, + previousRetainedCsvDirectory, + ) + restoreObserver(RestoreCheckpoint.RETAINED_CSV_RETIRED) + } writeRestoreState(RestoreState.COMMITTED) restoreObserver(RestoreCheckpoint.COMMITTED) cleanupCommittedRestore( 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 61775b0..ff19aed 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -18,15 +18,15 @@ import java.time.format.DateTimeFormatter /** Validates and durably retains user-selected Platoon roster CSV evidence. */ class PlatoonCsvImportStore(private val directory: File) { - // Function Name: retain + // Function Name: prepare // Description: // - Reads a bounded UTF-8 roster CSV, validates its complete schema, and hashes its bytes. - // - Publishes the file under a deterministic identity with a durable temporary write. + // - Does not create files, allowing callers to preview a whole selection before confirmation. // Parameters: // - input: User-selected document stream owned by the caller. // Returns: - // - Stable retained file identity and whether identical bytes already existed. - fun retain(input: InputStream): RetainResult { + // - 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() .onMalformedInput(CodingErrorAction.REPORT) @@ -39,10 +39,28 @@ class PlatoonCsvImportStore(private val directory: File) { val hash = MessageDigest.getInstance("SHA-256").digest(bytes) .take(HASH_BYTES) .joinToString("") { "%02x".format(it) } - val file = File( - directory, - "import-${FILE_TIME.format(capturedAt)}-$hash.csv", + return PreparedImport( + encoded = bytes, + snapshot = parsed, + capturedAt = capturedAt, + fileName = "import-" + FILE_TIME.format(capturedAt) + "-" + hash + ".csv", ) + } + + 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. + fun retain(prepared: PreparedImport): RetainResult { + val bytes = prepared.encoded + val capturedAt = prepared.capturedAt + val file = File(directory, prepared.fileName) directory.mkdirs() require(directory.isDirectory) { "Unable to create the Platoon CSV directory" } if (file.isFile) { @@ -78,6 +96,13 @@ 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) @@ -94,6 +119,16 @@ class PlatoonCsvImportStore(private val directory: File) { data class RetainResult(val file: File, val duplicate: Boolean) + class PreparedImport internal constructor( + internal val encoded: ByteArray, + val snapshot: GuildMembersCsv.Snapshot, + val capturedAt: Instant, + val fileName: String, + ) { + val byteCount: Int + get() = encoded.size + } + companion object { internal fun latestRetainedFile(directory: File): File? = directory.listFiles() .orEmpty() 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 aa61844..6aad93e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -1903,16 +1903,7 @@ class PlatoonDatabase( @Synchronized fun listSnapshots(limit: Int = 100): List { require(limit in 1..1000) - return querySnapshots( - selectedSnapshotsSql = - """ - SELECT id, captured_at, source_file, game_version - FROM snapshots - ORDER BY captured_at DESC, id DESC - LIMIT ? - """.trimIndent(), - selectionArgs = arrayOf(limit.toString()), - ) + return WeeklySnapshotStore.list(readableDatabase, limit) } /** @@ -1923,27 +1914,7 @@ class PlatoonDatabase( @Synchronized fun listSnapshotsForPeriod(from: Instant, until: Instant): List { require(until.isAfter(from)) - return querySnapshots( - selectedSnapshotsSql = - """ - SELECT id, captured_at, source_file, game_version - FROM snapshots - WHERE (captured_at >= ? AND captured_at <= ?) - OR id = ( - SELECT id - FROM snapshots - WHERE captured_at < ? - ORDER BY captured_at DESC, id DESC - LIMIT 1 - ) - ORDER BY captured_at DESC, id DESC - """.trimIndent(), - selectionArgs = arrayOf( - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - from.toEpochMilli().toString(), - ), - ) + return WeeklySnapshotStore.listForPeriod(readableDatabase, from, until) } @Synchronized @@ -3536,47 +3507,6 @@ class PlatoonDatabase( } } - private fun querySnapshots( - selectedSnapshotsSql: String, - selectionArgs: Array, - ): List = readableDatabase.rawQuery( - """ - SELECT selected.id, selected.captured_at, selected.source_file, selected.game_version, - member.uid, member.name, member.level, member.weekly_merit, - member.total_merit, member.high_score, member.total_score, member.last_login - FROM ($selectedSnapshotsSql) AS selected - LEFT JOIN snapshot_members AS member ON member.snapshot_id = selected.id - ORDER BY selected.captured_at DESC, selected.id DESC, - member.name COLLATE NOCASE, member.uid - """.trimIndent(), - selectionArgs, - ).use { cursor -> - val snapshots = linkedMapOf() - while (cursor.moveToNext()) { - val snapshotId = cursor.getLong(0) - val snapshot = snapshots.getOrPut(snapshotId) { - SnapshotAccumulator( - id = snapshotId, - capturedAt = Instant.ofEpochMilli(cursor.getLong(1)), - sourceFile = cursor.getNullableString(2), - gameVersion = cursor.getNullableString(3), - ) - } - if (!cursor.isNull(4)) { - snapshot.members += SnapshotMember( - uid = cursor.getLong(4), - name = cursor.getString(5), - level = cursor.getLong(6), - weeklyMerit = cursor.getLong(7), - totalMerit = cursor.getLong(8), - highScore = cursor.getLong(9), - totalScore = cursor.getLong(10), - lastLogin = cursor.getLong(11), - ) - } - } - snapshots.values.map(SnapshotAccumulator::toSnapshot) - } private fun readMembershipPeriods(db: SQLiteDatabase, uid: Long): List = db.query( @@ -3684,21 +3614,6 @@ class PlatoonDatabase( val leftSource: EvidenceSource?, ) - private data class SnapshotAccumulator( - val id: Long, - val capturedAt: Instant, - val sourceFile: String?, - val gameVersion: String?, - val members: MutableList = mutableListOf(), - ) { - fun toSnapshot() = PlatoonSnapshot( - id = id, - capturedAt = capturedAt, - sourceFile = sourceFile, - gameVersion = gameVersion, - members = members, - ) - } private data class ShadowMembershipPeriod( val joinedAt: Long?, 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 446623f..9a4df0d 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -177,6 +177,9 @@ class PlatoonRepository(context: Context) { fun listSnapshots(limit: Int = 100): List = access { it.listSnapshots(limit) } + fun representedSnapshotSources(): Set = + access(PlatoonDatabase::snapshotSourceFiles) + fun hasSnapshotSource(sourceFile: String): Boolean = access { sourceFile in it.snapshotSourceFiles() } diff --git a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt new file mode 100644 index 0000000..856df34 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt @@ -0,0 +1,277 @@ +package dev.gf2log.app.management + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Resolves the standard-week merit counter independently from report assembly. + * It exposes only values shared by every compatible daily allocation and keeps + * ambiguous closed days unknown. + */ +object StandardWeekSolver { + fun resolve( + uid: Long, + days: List, + zoneId: ZoneId, + snapshots: List, + cells: List, + asOf: Instant, + ): List { + val mutable = inferSundayMerit( + uid = uid, + days = days, + zoneId = zoneId, + snapshots = snapshots, + cells = cells, + ).toMutableList() + val observations = snapshots.mapNotNull { snapshot -> + snapshot.member(uid)?.let { member -> + CounterObservation( + capturedAt = snapshot.capturedAt, + gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId), + counter = member.weeklyMerit, + lastLoginDay = member.lastLogin + .takeIf { it > 0L } + ?.let(Instant::ofEpochSecond) + ?.let { PlatoonPeriods.gameDay(it, zoneId) }, + ) + } + } + val monday = days.first().plusDays(1) + val observationsInCounterWeek = observations.filter { it.gameDay in monday..days.last() } + val latest = observationsInCounterWeek.maxByOrNull(CounterObservation::capturedAt) + ?: return mutable + val latestDay = latest.gameDay + val firstObserved = observationsInCounterWeek.minBy(CounterObservation::capturedAt) + val knownAbsentBeforeFirstObservation = snapshots.any { snapshot -> + val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) + !gameDay.isBefore(monday) && + gameDay.isBefore(firstObserved.gameDay) && + snapshot.capturedAt.isBefore(firstObserved.capturedAt) && + snapshot.member(uid) == null + } + val activeStart = if (knownAbsentBeforeFirstObservation) firstObserved.gameDay else monday + val activeIndexes = days.indices.filter { index -> days[index] in activeStart..latestDay } + if (activeIndexes.isEmpty()) return mutable + + val confirmedNoLoginDays = activeIndexes.mapNotNull { index -> + val day = days[index] + val start = PlatoonPeriods.periodStartInstant(day, zoneId) + val end = PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId) + day.takeIf { + !end.isAfter(asOf) && + snapshots.any { snapshot -> + !snapshot.capturedAt.isBefore(end) && + snapshot.member(uid)?.lastLogin?.takeIf { it > 0L }?.let { + Instant.ofEpochSecond(it).isBefore(start) + } == true + } + } + }.toSet() + val compatible = compatibleAllocations( + indexes = activeIndexes, + days = days, + cells = mutable, + observations = observationsInCounterWeek, + confirmedNoLoginDays = confirmedNoLoginDays, + ) + if (compatible.isEmpty()) return mutable + + val selected = compatible.minWith( + compareBy> { it.sum() } + .thenComparator { left, right -> + left.indices.firstNotNullOfOrNull { index -> + java.lang.Long.compare(right[index], left[index]).takeIf { it != 0 } + } ?: 0 + }, + ) + activeIndexes.forEachIndexed { position, index -> + val existing = mutable[index] + if (existing.manualOverride != null) return@forEachIndexed + val selectedMerit = selected[position] + val exactAcrossCandidates = compatible.all { it[position] == selectedMerit } + val day = days[index] + val dayClosed = !PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId).isAfter(asOf) + val latestCheckpoint = observationsInCounterWeek + .filter { it.gameDay == day } + .maxByOrNull(CounterObservation::capturedAt) + val stageValues = latestCheckpoint?.let { checkpoint -> + compatible.mapNotNull { allocation -> + checkpointStage( + allocation = allocation, + indexes = activeIndexes, + days = days, + checkpoint = checkpoint, + ) + } + }.orEmpty() + val currentLowerBound = stageValues.minOrNull()?.takeIf { it > 0L } + val openLatestDay = day == latestDay && !dayClosed + val merit = when { + existing.hasDailyPatrolFact -> MAX_DAILY_MERIT + openLatestDay -> currentLowerBound + exactAcrossCandidates -> selectedMerit + else -> null + } + val evidence = when { + existing.hasDailyPatrolFact -> DailyEvidence.ATTRIBUTED + openLatestDay && currentLowerBound == MAX_DAILY_MERIT -> DailyEvidence.ATTRIBUTED + openLatestDay -> DailyEvidence.PARTIAL_DAY + exactAcrossCandidates -> DailyEvidence.ATTRIBUTED + else -> DailyEvidence.SPARSE_INFERRED + } + mutable[index] = existing.copy( + meritDelta = merit, + scoreDelta = 0L, + inference = merit?.let { + ActivityInference.infer( + meritDelta = it, + scoreDelta = 0L, + gunsmokeActive = false, + ) + }, + evidence = evidence, + ) + } + return mutable + } + + private fun inferSundayMerit( + uid: Long, + days: List, + zoneId: ZoneId, + snapshots: List, + cells: List, + ): List { + val sundayCell = cells.first() + if (sundayCell.manualOverride != null || sundayCell.evidence == DailyEvidence.ATTRIBUTED) { + return cells + } + val monday = days.first().plusDays(1) + val mondayStart = PlatoonPeriods.periodStartInstant(monday, zoneId) + val sundayObservation = snapshots.lastOrNull { snapshot -> + snapshot.capturedAt.isBefore(mondayStart) && + PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) == days.first() && + snapshot.member(uid) != null + } ?: return cells + val laterObservation = snapshots.firstOrNull { snapshot -> + !snapshot.capturedAt.isBefore(mondayStart) && + PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) in monday..days.last() && + snapshot.member(uid) != null + } ?: return cells + val sundayMember = requireNotNull(sundayObservation.member(uid)) + val laterMember = requireNotNull(laterObservation.member(uid)) + val remainingSundayMerit = laterMember.totalMerit - + sundayMember.totalMerit - + laterMember.weeklyMerit + if (remainingSundayMerit !in DAILY_STAGE) return cells + + val observedSundayPrefix = sundayCell.meritDelta ?: return cells + val merit = observedSundayPrefix + remainingSundayMerit + if (merit !in DAILY_STAGE) return cells + return cells.toMutableList().apply { + this[0] = sundayCell.copy( + meritDelta = merit, + scoreDelta = 0L, + inference = ActivityInference.infer( + meritDelta = merit, + scoreDelta = 0L, + gunsmokeActive = false, + ), + evidence = DailyEvidence.ATTRIBUTED, + ) + } + } + + private fun compatibleAllocations( + indexes: List, + days: List, + cells: List, + observations: List, + confirmedNoLoginDays: Set, + ): List> { + val results = mutableListOf>() + val candidate = mutableListOf() + + fun search(position: Int) { + if (position == indexes.size) { + if (observations.all { checkpointStage(candidate, indexes, days, it) != null }) { + results += candidate.toList() + } + return + } + val index = indexes[position] + val cell = cells[index] + val fixed = cell.manualOverride?.meritDelta + ?: PATROL_MERIT.takeIf { cell.hasDailyPatrolFact } + ?: 0L.takeIf { days[index] in confirmedNoLoginDays } + val options = when { + fixed != null -> longArrayOf(fixed) + cell.hasLoginFact -> longArrayOf(MAX_DAILY_MERIT, LOGIN_MERIT) + else -> DAILY_MERIT + } + options.forEach { merit -> + candidate += merit + val partialIsCompatible = observations + .filter { observation -> + val observationIndex = days.indexOf(observation.gameDay) + observationIndex >= 0 && observationIndex <= index + } + .all { observation -> + val observationIndex = days.indexOf(observation.gameDay) + observationIndex > index || checkpointStage( + candidate, + indexes.take(candidate.size), + days, + observation, + ) != null + } + if (partialIsCompatible) search(position + 1) + candidate.removeAt(candidate.lastIndex) + } + } + + search(position = 0) + return results + } + + private fun checkpointStage( + allocation: List, + indexes: List, + days: List, + checkpoint: CounterObservation, + ): Long? { + val checkpointIndex = days.indexOf(checkpoint.gameDay) + val position = indexes.indexOf(checkpointIndex) + if (position !in allocation.indices) return null + val completedPrefix = allocation.take(position).sum() + val stage = checkpoint.counter - completedPrefix + val finalForDay = allocation[position] + if (stage !in DAILY_STAGE || stage > finalForDay) return null + val loginStageCompatible = when { + checkpoint.lastLoginDay == checkpoint.gameDay -> + stage == LOGIN_MERIT || stage == PATROL_MERIT + checkpoint.lastLoginDay != null && + checkpoint.lastLoginDay.isBefore(checkpoint.gameDay) -> stage == 0L + else -> true + } + 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, + val counter: Long, + 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) +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt new file mode 100644 index 0000000..27aea9f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt @@ -0,0 +1,201 @@ +package dev.gf2log.app.management + +import java.time.LocalDate + +/** Builds user-facing evidence explanations without depending on Android UI classes. */ +object WeeklyEvidenceAnalyzer { + enum class Metric { + MERIT, + SCORE, + ATTEMPTS, + LOGIN, + DAILY_PATROL, + } + + enum class Fact { + MANUAL_OVERRIDE, + EXACT_CLOSING_BOUNDARY, + FINAL_GUNSMOKE_SCORE, + EXACT_DAILY_PATROL_EVENT, + LOGIN_TIMESTAMP, + SOLVER_CONSENSUS, + DAILY_CAP_REACHED, + WEEKLY_CAP_REACHED, + ALL_DAYS_EXACT, + CONFIRMED_LOWER_BOUND, + NO_OBSERVATION, + INCOMPLETE_BOUNDARY, + PARTIAL_DAY, + SPARSE_INFERENCE, + AMBIGUOUS_ALLOCATION, + } + + data class Explanation( + val metric: Metric, + val gameDay: LocalDate?, + val certainty: MetricCertainty, + val facts: List, + ) + + data class Health( + val observedDays: Int, + val totalDays: Int, + val exactMetrics: Int, + val lowerBoundMetrics: Int, + val unknownMetrics: Int, + val directLoginDays: Int, + val directPatrolDays: Int, + val closingBoundaries: Int, + ) { + val isComplete: Boolean + get() = unknownMetrics == 0 && lowerBoundMetrics == 0 + } + + fun explainDaily( + cell: WeeklyReportBuilder.DayCell, + metric: Metric, + ): Explanation { + val certainty = dailyCertainty(cell, metric) + val facts = linkedSetOf() + if (cell.manualOverride?.hasMetric(metric) == true) facts += Fact.MANUAL_OVERRIDE + if (cell.hasClosingBoundary) facts += Fact.EXACT_CLOSING_BOUNDARY + if (cell.hasFinalGunsmokeScore && metric == Metric.SCORE) facts += Fact.FINAL_GUNSMOKE_SCORE + if (cell.hasDailyPatrolFact && metric in setOf(Metric.MERIT, Metric.LOGIN, Metric.DAILY_PATROL)) { + facts += Fact.EXACT_DAILY_PATROL_EVENT + } + if (cell.hasLoginFact && metric in setOf(Metric.MERIT, Metric.LOGIN)) { + facts += Fact.LOGIN_TIMESTAMP + } + if (cell.hasSolverResult(metric)) facts += Fact.SOLVER_CONSENSUS + if ( + metric == Metric.ATTEMPTS && + cell.attempts == ActivityInference.MAX_DAILY_ATTEMPTS && + cell.attemptsCertainty == MetricCertainty.EXACT + ) { + facts += Fact.DAILY_CAP_REACHED + } + if (certainty == MetricCertainty.LOWER_BOUND) facts += Fact.CONFIRMED_LOWER_BOUND + when (cell.evidence) { + DailyEvidence.NO_OBSERVATION -> facts += Fact.NO_OBSERVATION + DailyEvidence.INCOMPLETE_BOUNDARY -> facts += Fact.INCOMPLETE_BOUNDARY + DailyEvidence.PARTIAL_DAY -> facts += Fact.PARTIAL_DAY + DailyEvidence.SPARSE_INFERRED -> facts += Fact.SPARSE_INFERENCE + DailyEvidence.ATTRIBUTED, DailyEvidence.MANUAL -> Unit + } + if (certainty == MetricCertainty.UNKNOWN && cell.inference?.precision == EvidencePrecision.AMBIGUOUS) { + facts += Fact.AMBIGUOUS_ALLOCATION + } + return Explanation(metric, cell.gameDay, certainty, facts.toList()) + } + + fun explainTotal( + member: WeeklyReportBuilder.MemberRow, + metric: Metric, + ): Explanation { + val certainty = totalCertainty(member, metric) + val facts = linkedSetOf() + if (member.days.any { it.manualOverride?.hasMetric(metric) == true }) { + facts += Fact.MANUAL_OVERRIDE + } + if (certainty == MetricCertainty.EXACT && member.days.all { dailyCertainty(it, metric) == MetricCertainty.EXACT }) { + facts += Fact.ALL_DAYS_EXACT + } + if ( + metric == Metric.ATTEMPTS && + member.totalAttempts == ActivityInference.MAX_WEEKLY_ATTEMPTS && + member.totalAttemptsCertainty == MetricCertainty.EXACT + ) { + facts += Fact.WEEKLY_CAP_REACHED + } + if (member.resolvedGunsmokeTotals != null && certainty == MetricCertainty.EXACT) { + facts += Fact.SOLVER_CONSENSUS + } + if (member.hasFinalGunsmokeScore && metric == Metric.SCORE) facts += Fact.FINAL_GUNSMOKE_SCORE + if (certainty == MetricCertainty.LOWER_BOUND) facts += Fact.CONFIRMED_LOWER_BOUND + if (certainty == MetricCertainty.UNKNOWN) { + facts += if (member.days.any { it.evidence == DailyEvidence.SPARSE_INFERRED }) { + Fact.SPARSE_INFERENCE + } else { + Fact.NO_OBSERVATION + } + } + return Explanation(metric, gameDay = null, certainty, facts.toList()) + } + + fun health(report: WeeklyReportBuilder.Report): Health { + val cells = report.members.flatMap(WeeklyReportBuilder.MemberRow::days) + val certainties = cells.flatMap { cell -> + buildList { + add(cell.meritCertainty) + add(cell.attended.toCertainty(cell.observed)) + add(cell.dailyPatrol.toCertainty(cell.observed)) + if (report.isGunsmokeWeek) { + add(cell.scoreCertainty) + add(cell.attemptsCertainty) + } + } + } + return Health( + observedDays = report.days.count { day -> cells.any { it.gameDay == day && it.observed } }, + totalDays = report.days.size, + exactMetrics = certainties.count { it == MetricCertainty.EXACT }, + lowerBoundMetrics = certainties.count { it == MetricCertainty.LOWER_BOUND }, + unknownMetrics = certainties.count { it == MetricCertainty.UNKNOWN }, + directLoginDays = cells.filter(WeeklyReportBuilder.DayCell::hasLoginFact) + .map(WeeklyReportBuilder.DayCell::gameDay) + .distinct() + .size, + directPatrolDays = cells.filter(WeeklyReportBuilder.DayCell::hasDailyPatrolFact) + .map(WeeklyReportBuilder.DayCell::gameDay) + .distinct() + .size, + closingBoundaries = report.days.count { day -> + cells.any { it.gameDay == day && it.hasClosingBoundary } + }, + ) + } + + private fun dailyCertainty( + cell: WeeklyReportBuilder.DayCell, + metric: Metric, + ): MetricCertainty = when (metric) { + 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) + } + + private fun totalCertainty( + member: WeeklyReportBuilder.MemberRow, + metric: Metric, + ): MetricCertainty = when (metric) { + Metric.MERIT -> member.totalMeritCertainty + Metric.SCORE -> member.totalScoreCertainty + Metric.ATTEMPTS -> member.totalAttemptsCertainty + Metric.LOGIN -> member.loginDaysCertainty + Metric.DAILY_PATROL -> member.patrolDaysCertainty + } + + private fun WeeklyReportBuilder.DayCell.hasSolverResult(metric: Metric): Boolean = when (metric) { + Metric.MERIT -> solvedMeritCertainty != null + Metric.SCORE -> solvedScoreCertainty != null + Metric.ATTEMPTS -> solvedAttemptsCertainty != null + Metric.LOGIN -> solvedAttended != null + Metric.DAILY_PATROL -> solvedDailyPatrol != null + } + + private fun WeeklyCellOverride.hasMetric(metric: Metric): Boolean = when (metric) { + Metric.MERIT -> meritDelta != null + Metric.SCORE -> scoreDelta != null + Metric.ATTEMPTS -> attempts != null + Metric.LOGIN -> attended != null + Metric.DAILY_PATROL -> dailyPatrol != null + } + + private fun Boolean?.toCertainty(observed: Boolean): MetricCertainty = when { + this != null -> MetricCertainty.EXACT + observed -> MetricCertainty.UNKNOWN + else -> MetricCertainty.UNKNOWN + } +} 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 46228e1..445fc5b 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -377,7 +377,7 @@ object WeeklyReportBuilder { null } val cells = gunsmokeResolution?.cells ?: if (!isGunsmoke) { - inferStandardWeekCells( + StandardWeekSolver.resolve( uid = latestKnown.uid, days = days, zoneId = zoneId, @@ -687,279 +687,6 @@ object WeeklyReportBuilder { else -> MetricCertainty.LOWER_BOUND } - /** - * Reconciles Monday-through-Saturday cells with the latest weekly counter. - * - * The game's weekly counter resets on Monday while this report starts on - * Sunday. Every standard-week merit day is one of 0/50/90, so the - * captured cumulative counter can be solved into all compatible daily - * sequences. Values shared by every sequence are exact. Ambiguous closed - * days remain unknown, while the latest in-progress stage is a lower bound - * until it reaches the daily maximum. - */ - private fun inferStandardWeekCells( - uid: Long, - days: List, - zoneId: ZoneId, - snapshots: List, - cells: List, - asOf: Instant, - ): List { - val mutable = inferSundayMerit( - uid = uid, - days = days, - zoneId = zoneId, - snapshots = snapshots, - cells = cells, - ).toMutableList() - val observations = snapshots.mapNotNull { snapshot -> - snapshot.member(uid)?.let { member -> - CounterObservation( - capturedAt = snapshot.capturedAt, - gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId), - counter = member.weeklyMerit, - lastLoginDay = member.lastLogin - .takeIf { it > 0L } - ?.let(Instant::ofEpochSecond) - ?.let { PlatoonPeriods.gameDay(it, zoneId) }, - ) - } - } - val monday = days.first().plusDays(1) - val observationsInCounterWeek = observations.filter { it.gameDay in monday..days.last() } - val latest = observationsInCounterWeek.maxByOrNull(CounterObservation::capturedAt) - ?: return mutable - val latestDay = latest.gameDay - val firstObserved = observationsInCounterWeek.minBy(CounterObservation::capturedAt) - val knownAbsentBeforeFirstObservation = snapshots.any { snapshot -> - val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) - !gameDay.isBefore(monday) && - gameDay.isBefore(firstObserved.gameDay) && - snapshot.capturedAt.isBefore(firstObserved.capturedAt) && - snapshot.member(uid) == null - } - val activeStart = if (knownAbsentBeforeFirstObservation) firstObserved.gameDay else monday - val activeIndexes = days.indices.filter { index -> days[index] in activeStart..latestDay } - if (activeIndexes.isEmpty()) return mutable - - val confirmedNoLoginDays = activeIndexes.mapNotNull { index -> - val day = days[index] - val start = PlatoonPeriods.periodStartInstant(day, zoneId) - val end = PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId) - day.takeIf { - !end.isAfter(asOf) && - snapshots.any { snapshot -> - !snapshot.capturedAt.isBefore(end) && - snapshot.member(uid)?.lastLogin?.takeIf { it > 0L }?.let { - Instant.ofEpochSecond(it).isBefore(start) - } == true - } - } - }.toSet() - val compatible = compatibleStandardAllocations( - indexes = activeIndexes, - days = days, - cells = mutable, - observations = observationsInCounterWeek, - confirmedNoLoginDays = confirmedNoLoginDays, - ) - if (compatible.isEmpty()) return mutable - - // Selection is used only when every compatible allocation agrees for a - // day. Ambiguous positions never expose an arbitrary allocation. - val selected = compatible.minWith( - compareBy> { it.sum() } - .thenComparator { left, right -> - left.indices.firstNotNullOfOrNull { index -> - java.lang.Long.compare(right[index], left[index]).takeIf { it != 0 } - } ?: 0 - }, - ) - activeIndexes.forEachIndexed { position, index -> - val existing = mutable[index] - if (existing.manualOverride != null) return@forEachIndexed - val selectedMerit = selected[position] - val exactAcrossCandidates = compatible.all { it[position] == selectedMerit } - val day = days[index] - val dayClosed = !PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId).isAfter(asOf) - val latestCheckpoint = observationsInCounterWeek - .filter { it.gameDay == day } - .maxByOrNull(CounterObservation::capturedAt) - val stageValues = latestCheckpoint?.let { checkpoint -> - compatible.mapNotNull { allocation -> - checkpointStage( - allocation = allocation, - indexes = activeIndexes, - days = days, - checkpoint = checkpoint, - ) - } - }.orEmpty() - val currentLowerBound = stageValues.minOrNull()?.takeIf { it > 0L } - val openLatestDay = day == latestDay && !dayClosed - val merit = when { - existing.hasDailyPatrolFact -> MAX_STANDARD_DAILY_MERIT - openLatestDay -> currentLowerBound - exactAcrossCandidates -> selectedMerit - else -> null - } - val evidence = when { - existing.hasDailyPatrolFact -> DailyEvidence.ATTRIBUTED - openLatestDay && currentLowerBound == MAX_STANDARD_DAILY_MERIT -> - DailyEvidence.ATTRIBUTED - openLatestDay -> DailyEvidence.PARTIAL_DAY - exactAcrossCandidates -> DailyEvidence.ATTRIBUTED - else -> DailyEvidence.SPARSE_INFERRED - } - mutable[index] = existing.copy( - meritDelta = merit, - scoreDelta = 0L, - inference = merit?.let { - ActivityInference.infer( - meritDelta = it, - scoreDelta = 0L, - gunsmokeActive = false, - ) - }, - evidence = evidence, - ) - } - return mutable - } - - private fun inferSundayMerit( - uid: Long, - days: List, - zoneId: ZoneId, - snapshots: List, - cells: List, - ): List { - val sundayCell = cells.first() - if (sundayCell.manualOverride != null || sundayCell.evidence == DailyEvidence.ATTRIBUTED) { - return cells - } - val monday = days.first().plusDays(1) - val mondayStart = PlatoonPeriods.periodStartInstant(monday, zoneId) - val sundayObservation = snapshots.lastOrNull { snapshot -> - snapshot.capturedAt.isBefore(mondayStart) && - PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) == days.first() && - snapshot.member(uid) != null - } ?: return cells - val laterObservation = snapshots.firstOrNull { snapshot -> - !snapshot.capturedAt.isBefore(mondayStart) && - PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) in monday..days.last() && - snapshot.member(uid) != null - } ?: return cells - val sundayMember = requireNotNull(sundayObservation.member(uid)) - val laterMember = requireNotNull(laterObservation.member(uid)) - val remainingSundayMerit = laterMember.totalMerit - - sundayMember.totalMerit - - laterMember.weeklyMerit - if (remainingSundayMerit !in STANDARD_DAILY_STAGE) return cells - - val observedSundayPrefix = sundayCell.meritDelta ?: return cells - val merit = observedSundayPrefix + remainingSundayMerit - if (merit !in STANDARD_DAILY_STAGE) return cells - return cells.toMutableList().apply { - this[0] = sundayCell.copy( - meritDelta = merit, - scoreDelta = 0L, - inference = ActivityInference.infer( - meritDelta = merit, - scoreDelta = 0L, - gunsmokeActive = false, - ), - evidence = DailyEvidence.ATTRIBUTED, - ) - } - } - - private fun compatibleStandardAllocations( - indexes: List, - days: List, - cells: List, - observations: List, - confirmedNoLoginDays: Set, - ): List> { - val results = mutableListOf>() - val candidate = mutableListOf() - - fun search(position: Int) { - if (position == indexes.size) { - if ( - observations.all { observation -> - checkpointStage(candidate, indexes, days, observation) != null - } - ) { - results += candidate.toList() - } - return - } - val index = indexes[position] - val cell = cells[index] - val fixed = cell.manualOverride?.meritDelta - ?: STANDARD_PATROL_MERIT.takeIf { cell.hasDailyPatrolFact } - ?: 0L.takeIf { days[index] in confirmedNoLoginDays } - val options = when { - fixed != null -> longArrayOf(fixed) - cell.hasLoginFact -> longArrayOf(MAX_STANDARD_DAILY_MERIT, STANDARD_LOGIN_MERIT) - else -> STANDARD_DAILY_MERIT - } - options.forEach { merit -> - candidate += merit - val partialIsCompatible = observations - .filter { observation -> - val observationIndex = days.indexOf(observation.gameDay) - observationIndex >= 0 && observationIndex <= index - } - .all { observation -> - val observationIndex = days.indexOf(observation.gameDay) - if (observationIndex > index) { - true - } else { - checkpointStage(candidate, indexes.take(candidate.size), days, observation) != null - } - } - if (partialIsCompatible) { - search(position + 1) - } - candidate.removeAt(candidate.lastIndex) - } - } - - search(position = 0) - return results - } - - private fun checkpointStage( - allocation: List, - indexes: List, - days: List, - checkpoint: CounterObservation, - ): Long? { - val checkpointIndex = days.indexOf(checkpoint.gameDay) - val position = indexes.indexOf(checkpointIndex) - if (position !in allocation.indices) return null - val completedPrefix = allocation.take(position).sum() - val stage = checkpoint.counter - completedPrefix - val finalForDay = allocation[position] - if (stage !in STANDARD_DAILY_STAGE || stage > finalForDay) return null - val loginStageCompatible = when { - checkpoint.lastLoginDay == checkpoint.gameDay -> - stage == STANDARD_LOGIN_MERIT || stage == STANDARD_PATROL_MERIT - checkpoint.lastLoginDay != null && - checkpoint.lastLoginDay.isBefore(checkpoint.gameDay) -> stage == 0L - else -> true - } - return stage.takeIf { loginStageCompatible } - } - - private data class CounterObservation( - val capturedAt: Instant, - val gameDay: LocalDate, - val counter: Long, - val lastLoginDay: LocalDate?, - ) private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = members.firstOrNull { it.uid == uid } @@ -972,6 +699,4 @@ object WeeklyReportBuilder { 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 val STANDARD_DAILY_MERIT = longArrayOf(90L, 50L, 0L) - private val STANDARD_DAILY_STAGE = setOf(0L, 50L, 90L) } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt new file mode 100644 index 0000000..baf5a2e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt @@ -0,0 +1,50 @@ +package dev.gf2log.app.management + +import java.time.LocalDate + +/** + * Owns weekly-screen navigation and asynchronous render validity independently + * from Android views. A monotonically increasing generation makes a result + * applicable only to the request that is still current while the screen is + * resumed. + */ +class WeeklyReportStateHolder(initialReferenceDay: LocalDate) { + var referenceDay: LocalDate = initialReferenceDay + private set + + private var resumed = false + private var generation = 0 + + data class RenderRequest( + val referenceDay: LocalDate, + val generation: Int, + ) + + fun onResume() { + resumed = true + } + + fun onPause() { + resumed = false + generation += 1 + } + + fun newRenderRequest(): RenderRequest = RenderRequest( + referenceDay = referenceDay, + generation = ++generation, + ) + + fun canApply(generation: Int): Boolean = resumed && generation == this.generation + + fun showPreviousWeek(periodStart: LocalDate) { + referenceDay = periodStart.minusDays(1) + } + + fun showNextWeek(periodEnd: LocalDate) { + referenceDay = periodEnd.plusDays(1) + } + + fun selectDate(date: LocalDate) { + referenceDay = date + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt new file mode 100644 index 0000000..a40efe1 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt @@ -0,0 +1,110 @@ +package dev.gf2log.app.management + +import java.time.format.DateTimeFormatter + +/** Builds the exact privacy-filtered content that may leave the Android sandbox as an image. */ +object WeeklyShareProjection { + data class Privacy( + val includeMemberNames: Boolean = true, + val includeUids: Boolean = false, + val includePrivateNotes: Boolean = false, + ) + + data class Document( + val title: String, + val subtitle: String, + val headers: List, + val rows: List, + val includeNotes: Boolean, + val evidenceHealth: WeeklyEvidenceAnalyzer.Health, + ) + + data class Row( + val member: String, + val dailyCells: List, + val total: String, + val privateNote: String?, + ) + + fun build( + report: WeeklyReportBuilder.Report, + displayedMembers: List, + privateNotesByUid: Map, + privacy: Privacy, + ): Document { + val reportUids = report.members.mapTo(mutableSetOf()) { it.uid } + require(displayedMembers.all { it.uid in reportUids }) + val headers = buildList { + add("Member") + addAll(report.days.map { it.format(DAY) }) + add("Total") + if (privacy.includePrivateNotes) add("Private note") + } + 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), + privateNote = privateNotesByUid[member.uid] + ?.take(MAX_PRIVATE_NOTE_CHARS) + ?.takeIf { privacy.includePrivateNotes && it.isNotBlank() }, + ) + } + return Document( + title = if (report.isGunsmokeWeek) "GF2logger \u2014 Gunsmoke week" else "GF2logger \u2014 Standard week", + subtitle = report.periodStart.toString() + " \u2014 " + report.periodEnd.toString(), + headers = headers, + rows = rows, + includeNotes = privacy.includePrivateNotes, + evidenceHealth = WeeklyEvidenceAnalyzer.health(report), + ) + } + + private fun memberLabel( + member: WeeklyReportBuilder.MemberRow, + index: Int, + privacy: Privacy, + ): String = when { + privacy.includeMemberNames && privacy.includeUids -> + member.name.take(MAX_MEMBER_LABEL_CHARS) + " (#" + member.uid + ")" + privacy.includeMemberNames -> member.name.take(MAX_MEMBER_LABEL_CHARS) + privacy.includeUids -> "#" + member.uid + else -> "Member " + (index + 1) + } + + private fun formatDaily( + cell: WeeklyReportBuilder.DayCell, + gunsmoke: Boolean, + ): String = buildList { + add("M " + 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("L " + activityMark(cell.attended)) + add("P " + activityMark(cell.dailyPatrol)) + }.joinToString("\n") + + private fun formatTotal( + member: WeeklyReportBuilder.MemberRow, + gunsmoke: Boolean, + ): String = buildList { + add("M " + 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("L " + WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty)) + add("P " + WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty)) + }.joinToString("\n") + + private fun activityMark(value: Boolean?): String = when (value) { + true -> "\u2713" + false -> "\u2715" + null -> "?" + } + + private val DAY = DateTimeFormatter.ofPattern("MM/dd") + 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/management/WeeklySnapshotStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklySnapshotStore.kt new file mode 100644 index 0000000..f1b0723 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklySnapshotStore.kt @@ -0,0 +1,108 @@ +package dev.gf2log.app.management + +import android.database.sqlite.SQLiteDatabase +import java.time.Instant + +/** + * Read-only SQL boundary for roster snapshots consumed by weekly projections. + * Schema/migration and write transactions remain owned by PlatoonDatabase. + */ +internal object WeeklySnapshotStore { + fun list(db: SQLiteDatabase, limit: Int): List = query( + db = db, + selectedSnapshotsSql = + """ + SELECT id, captured_at, source_file, game_version + FROM snapshots + ORDER BY captured_at DESC, id DESC + LIMIT ? + """.trimIndent(), + selectionArgs = arrayOf(limit.toString()), + ) + + fun listForPeriod( + db: SQLiteDatabase, + from: Instant, + until: Instant, + ): List = query( + db = db, + selectedSnapshotsSql = + """ + SELECT id, captured_at, source_file, game_version + FROM snapshots + WHERE (captured_at >= ? AND captured_at <= ?) + OR id = ( + SELECT id + FROM snapshots + WHERE captured_at < ? + ORDER BY captured_at DESC, id DESC + LIMIT 1 + ) + ORDER BY captured_at DESC, id DESC + """.trimIndent(), + selectionArgs = arrayOf( + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + from.toEpochMilli().toString(), + ), + ) + + private fun query( + db: SQLiteDatabase, + selectedSnapshotsSql: String, + selectionArgs: Array, + ): List = db.rawQuery( + """ + SELECT selected.id, selected.captured_at, selected.source_file, selected.game_version, + member.uid, member.name, member.level, member.weekly_merit, + member.total_merit, member.high_score, member.total_score, member.last_login + FROM ($selectedSnapshotsSql) AS selected + LEFT JOIN snapshot_members AS member ON member.snapshot_id = selected.id + ORDER BY selected.captured_at DESC, selected.id DESC, + member.name COLLATE NOCASE, member.uid + """.trimIndent(), + selectionArgs, + ).use { cursor -> + val snapshots = linkedMapOf() + while (cursor.moveToNext()) { + val snapshotId = cursor.getLong(0) + val snapshot = snapshots.getOrPut(snapshotId) { + SnapshotAccumulator( + id = snapshotId, + capturedAt = Instant.ofEpochMilli(cursor.getLong(1)), + sourceFile = if (cursor.isNull(2)) null else cursor.getString(2), + gameVersion = if (cursor.isNull(3)) null else cursor.getString(3), + ) + } + if (!cursor.isNull(4)) { + snapshot.members += SnapshotMember( + uid = cursor.getLong(4), + name = cursor.getString(5), + level = cursor.getLong(6), + weeklyMerit = cursor.getLong(7), + totalMerit = cursor.getLong(8), + highScore = cursor.getLong(9), + totalScore = cursor.getLong(10), + lastLogin = cursor.getLong(11), + ) + } + } + snapshots.values.map(SnapshotAccumulator::toSnapshot) + } + + private data class SnapshotAccumulator( + val id: Long, + val capturedAt: Instant, + val sourceFile: String?, + val gameVersion: String?, + val members: MutableList = mutableListOf(), + ) { + fun toSnapshot() = PlatoonSnapshot( + id = id, + capturedAt = capturedAt, + sourceFile = sourceFile, + gameVersion = gameVersion, + members = members, + ) + } +} diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml new file mode 100644 index 0000000..963f842 --- /dev/null +++ b/app/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..57576ce --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6f6b2fb..8b2aef9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -4,7 +4,9 @@ 선택한 게임 앱 하나의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다. 설치된 게임 패키지 이름 캡처 준비 - 서클 명단 한 번 캡처 + 안내형 캡처 (자동 중지) + 유용한 페이로드 수집 상태를 보여 주며 명단, 활동, 가입/탈퇴 정보가 모두 도착하면 중지합니다. + %1$s 서클 명단\n%2$s 활동 근거\n%3$s 가입/탈퇴 변경 중지 설정 열기 서클 관리 열기 @@ -253,4 +255,67 @@ 보관 기록 %1$d개를 삭제했습니다 + 근거 상태 + 관측 일수 %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 + 확정 + 확인된 최솟값이며 최종 값은 더 높을 수 있음 + 현재 캡처만으로 알 수 없음 + 사용자가 이 항목을 직접 확정했습니다. + 초기화 이후의 패킷이 이 일일 구간을 마감합니다. + 흙먼지 종료 이후의 최종 점수를 확인했습니다. + 시간이 기록된 일일 과업 이벤트가 이 판정을 직접 뒷받침합니다. + 시간이 기록된 로그인 관측이 이 판정을 직접 뒷받침합니다. + 가능한 모든 카운터 배치가 이 판정에 일치합니다. + 일일 참여 상한에 도달하여 값이 더 늘어날 수 없습니다. + 주간 참여 상한에 도달하여 값이 더 늘어날 수 없습니다. + 합계에 포함된 모든 일일 값이 확정되었습니다. + 현재 카운터로 최소 이 값은 확인되지만 누락된 근거가 값을 늘릴 수 있습니다. + 이 항목을 뒷받침하는 사용 가능한 관측이 없습니다. + 시작 또는 종료 카운터 경계가 누락되었습니다. + 캡처가 이 게임 날짜의 일부만 포함합니다. + 긴 공백 때문에 일일 배치를 하나로 확정할 수 없습니다. + 가능한 배치가 둘 이상이므로 애플리케이션은 임의로 추측하지 않습니다. + CSV 가져오기 검토 + 검증된 파일: %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아직 데이터는 변경되지 않았습니다. 가져오기 전에 자동 체크포인트를 생성합니다. + %1$s — %2$s + CSV 파일을 검증하고 미리보기를 준비하는 중… + 체크포인트를 만들고 CSV 파일을 가져오는 중… + CSV 가져오기 완료: 보관 %1$d, 현재 %2$d, 과거 %3$d, 건너뜀 %4$d. 실행 취소할 수 있습니다. + 마지막 CSV 가져오기 실행 취소 + 마지막 CSV 가져오기 직전에 만든 자동 체크포인트를 복원할까요? 가져오기 이후 서클 데이터가 변경되었다면 되돌리기를 거부합니다. + 사용 가능한 CSV 가져오기 체크포인트가 없습니다. + CSV 가져오기 체크포인트를 복원하는 중… + 마지막 CSV 가져오기를 실행 취소했습니다. + 체크포인트가 없거나 올바르지 않거나, 가져오기 이후 서클 데이터가 변경되어 되돌릴 수 없습니다. + CSV 미리보기, 가져오기 또는 실행 취소 전에 캡처를 중지하세요. + 주간 테이블 공유 + 멤버 이름 포함 + UID 포함 + 비공개 메모 포함 (민감 정보) + 활성화한 항목만 PNG에 렌더링됩니다. UID와 비공개 메모는 기본적으로 꺼져 있습니다. + PNG 저장 + PNG 공유 + 주간 PNG를 만드는 중… + 주간 PNG를 저장했습니다. + 주간 PNG를 만들거나 저장할 수 없습니다. + Discord 웹훅 + 파싱된 원본 CSV를 최소 권한으로 전송하는 선택 기능입니다. 대상 채널에서 수신 웹훅을 만들고 URL을 붙여 넣으세요. 토큰은 Android Keystore로 암호화되고 다시 표시되지 않으며 앱 백업에서 제외됩니다. + https://discord.com/api/webhooks/… + Discord 웹훅이 안전하게 설정되었습니다. + Discord 웹훅이 설정되지 않았습니다. + 웹훅 안전하게 저장 + 저장된 웹훅 제거 + Discord 웹훅을 안전하게 저장했습니다. + discord.com의 유효한 HTTPS 수신 웹훅 URL을 입력하세요. + 저장된 Discord 웹훅을 제거했습니다. + 저장된 Discord 웹훅을 제거할 수 없습니다. + 원본 CSV 전송 + 이 원본 CSV 파일을 설정된 Discord 채널로 전송할까요? 캡처된 서클 데이터가 앱 외부로 이동합니다. + 전송하기 전에 설정에서 Discord 수신 웹훅을 구성하세요. + 원본 CSV를 Discord로 전송하는 중… + 원본 CSV를 Discord로 전송했습니다. + 원본 CSV를 전송할 수 없습니다. 웹훅과 네트워크 연결을 확인하세요. + 이 파싱 패킷에는 전송할 수 있는 유효한 CSV 본문이 없습니다. diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index f047537..546afd4 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -3,4 +3,12 @@ #86AFFF #111214 #FF8AA4 + #E18484 + #111214 + #1F3A2A + #9DDBB3 + #4F8E68 + #3B3120 + #F2D28C + #A8853A diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2457d30..25f34c4 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -4,4 +4,12 @@ #315DA8 #FFFFFF #C43D5C + #C45B5B + #FFFFFF + #E4F4EA + #17633A + #7DBA93 + #FFF3D6 + #765311 + #D5B05A diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 801637b..a500594 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,9 @@ On-device protocol capture for one explicitly selected game package. Raw traffic is not stored. Installed game package name Prepare capture - Capture one Platoon roster + Guided capture (auto-stop) + Shows useful payload progress and stops after roster, activity, and update payloads arrive. + %1$s Platoon roster\n%2$s Activity evidence\n%3$s Membership updates Stop Open payload options Open Platoon management @@ -257,4 +259,67 @@ Deleted %1$d saved entry Deleted %1$d saved entries + Evidence Health + 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 + A user manually confirmed this field. + A post-reset boundary closes this daily interval. + The final Gunsmoke score was observed after the event ended. + A timestamped Daily Patrol event directly supports this answer. + A timestamped login observation directly supports this answer. + Every valid counter allocation agrees on this answer. + The daily attempt cap was reached, so the value cannot increase. + The weekly attempt cap was reached, so the value cannot increase. + All contributing daily values are confirmed. + Captured counters prove at least this much, but missing evidence may increase it. + No usable observation covers this field. + The starting or ending counter boundary is missing. + The capture covers only part of this game day. + A long gap prevents a unique daily allocation. + More than one valid allocation remains, so the app does not guess. + Review CSV import + 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. + %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. + 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. + Share weekly table + Include member names + Include UIDs + Include private notes (sensitive) + Only enabled fields are rendered into the PNG. UIDs and private notes are off by default. + Save PNG + Share PNG + Rendering weekly PNG… + Weekly PNG saved. + Unable to create or save the weekly PNG. + Discord webhook + Optional least-privilege delivery for an original parsed CSV. Create an incoming webhook in the destination channel and paste its URL here. The token is encrypted with Android Keystore, is never displayed again, and is excluded from app backups. + https://discord.com/api/webhooks/… + Discord webhook is configured securely. + Discord webhook is not configured. + Save webhook securely + Remove saved webhook + Discord webhook saved securely. + Enter a valid HTTPS incoming-webhook URL on discord.com. + Saved Discord webhook removed. + 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. + 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. diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..e775f92 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + 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 27a3bc2..357359e 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -1,7 +1,9 @@ package dev.gf2log.app.capture +import dev.gf2log.protocol.Gfl2PayloadDecoder import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -21,4 +23,21 @@ class CaptureStatusTest { assertFalse(CaptureStatus.isRunning) assertEquals("Capture failed", CaptureStatus.read()) } + + @Test + fun exposesImmutableGuidedCaptureProgressUntilTheNextSession() { + CaptureStatus.beginSession(guided = true) + CaptureStatus.markUsefulPayload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) + val progress = requireNotNull(CaptureStatus.readGuidedProgress()) + assertEquals( + setOf(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS), + progress.capturedPayloadTypes, + ) + + CaptureStatus.markStopped("Guided capture complete") + assertEquals(progress, CaptureStatus.readGuidedProgress()) + + CaptureStatus.beginSession(guided = false) + assertNull(CaptureStatus.readGuidedProgress()) + } } diff --git a/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt new file mode 100644 index 0000000..4b5675c --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt @@ -0,0 +1,44 @@ +package dev.gf2log.app.discord + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiscordWebhookPolicyTest { + @Test + fun acceptsOnlyExactHttpsDiscordIncomingWebhookEndpoints() { + val token = "a".repeat(64) + val accepted = DiscordWebhookPolicy.requireValid( + "https://discord.com/api/webhooks/12345678901234567/" + token, + ) + assertEquals("discord.com", accepted.host) + + listOf( + "http://discord.com/api/webhooks/12345678901234567/" + token, + "https://discord.com.evil.example/api/webhooks/12345678901234567/" + token, + "https://discord.com:444/api/webhooks/12345678901234567/" + token, + "https://discord.com/api/webhooks/12345678901234567/" + token + "?wait=true", + "https://discord.com/api/users/12345678901234567/" + token, + ).forEach { value -> + assertTrue(runCatching { DiscordWebhookPolicy.requireValid(value) }.isFailure) + } + } + + @Test + fun extractsOnlyCsvBodyFromValidatedHistoryEnvelope() { + val content = """ + payloadType=21917 + capturedAt=2026-08-11T00:00:00Z + + uid,name + 1,Alice + """.trimIndent() + + assertEquals("uid,name\n1,Alice", OriginalCsvPayload.extract(content)) + } + + @Test + fun malformedEnvelopeIsRejected() { + assertTrue(runCatching { OriginalCsvPayload.extract("uid,name\n1,Alice") }.isFailure) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt b/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt index 41a8aaa..24b1cb2 100644 --- a/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt @@ -41,6 +41,16 @@ class BackupArchiveTest { assertEquals(null, result.settings) } + @Test + fun `write leaves its caller-owned output open for durability sync`() { + val database = temporaryFile("platoon.db", realisticDatabaseBytes()) + val output = CloseTrackingOutputStream() + + BackupArchive.write(output, database, null) + + assertFalse(output.closed) + } + @Test fun `rejects corrupt and incomplete lookalike files without leaving staged data`() { val database = realisticDatabaseBytes() @@ -301,4 +311,14 @@ class BackupArchiveTest { delete() deleteOnExit() } + + private class CloseTrackingOutputStream : ByteArrayOutputStream() { + var closed = false + private set + + override fun close() { + closed = true + super.close() + } +} } diff --git a/app/src/test/java/dev/gf2log/app/management/CsvImportPreviewAnalyzerTest.kt b/app/src/test/java/dev/gf2log/app/management/CsvImportPreviewAnalyzerTest.kt new file mode 100644 index 0000000..f07025e --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/CsvImportPreviewAnalyzerTest.kt @@ -0,0 +1,186 @@ +package dev.gf2log.app.management + +import dev.gf2log.protocol.GuildMembersCsv +import dev.gf2log.protocol.model.GuildMember +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class CsvImportPreviewAnalyzerTest { + @Test + fun separatesHistoricalEvidenceFromProjectedCurrentRosterChanges() { + val baselineTime = Instant.parse("2026-08-10T00:00:00Z") + val existing = listOf( + status(1, "One", active = true), + status(2, "Old Name", active = true), + ) + val latest = PlatoonSnapshot( + id = 1, + capturedAt = baselineTime, + members = listOf(snapshotMember(1, "One"), snapshotMember(2, "Old Name")), + sourceFile = "baseline.csv", + ) + val prepared = listOf( + prepared( + "import-20260809T000000Z-00000000000000000001.csv", + "2026-08-09T00:00:00Z", + member(99, "Historical"), + ), + prepared( + "import-20260811T000000Z-00000000000000000002.csv", + "2026-08-11T00:00:00Z", + member(2, "New Name"), + member(3, "Three"), + ), + ) + + val preview = CsvImportPreviewAnalyzer.analyze( + prepared = prepared, + duplicateFileNames = emptySet(), + existingMembers = existing, + latestSnapshot = latest, + ) + + assertEquals(2, preview.validatedFiles) + assertEquals(1, preview.historicalFiles) + assertEquals(3, preview.uniqueMembers) + assertEquals(2, preview.newMembers) + assertEquals(1, preview.nameDifferences) + assertEquals(1, preview.potentialJoins) + assertEquals(1, preview.potentialWithdrawals) + } + + @Test + fun alreadyRetainedFilesHaveNoProjectedImpact() { + val item = prepared( + "import-20260811T000000Z-00000000000000000003.csv", + "2026-08-11T00:00:00Z", + member(3, "Three"), + ) + val preview = CsvImportPreviewAnalyzer.analyze( + prepared = listOf(item), + duplicateFileNames = setOf(item.fileName), + existingMembers = listOf(status(1, "One", active = true)), + latestSnapshot = null, + ) + + assertEquals(1, preview.duplicateFiles) + assertEquals(0, preview.uniqueMembers) + assertEquals(0, preview.potentialJoins) + assertEquals(0, preview.potentialWithdrawals) + } + + @Test + fun databaseRepresentedSourcesRemainDuplicatesWithoutRetainedFiles() { + val item = prepared( + "import-20260811T000000Z-00000000000000000004.csv", + "2026-08-11T00:00:00Z", + member(4, "Four"), + ) + + val duplicates = CsvImportPreviewAnalyzer.duplicateFileNames( + prepared = listOf(item), + representedSourceFiles = setOf(item.fileName), + ) + + assertEquals(setOf(item.fileName), duplicates) + } + + @Test + fun retainedOnlySourcesRemainActionableUntilRecoveryRepresentsThem() { + val item = prepared( + "import-20260811T000000Z-00000000000000000005.csv", + "2026-08-11T00:00:00Z", + member(5, "Five"), + ) + + val duplicates = CsvImportPreviewAnalyzer.duplicateFileNames( + prepared = listOf(item), + representedSourceFiles = emptySet(), + ) + + assertEquals(emptySet(), duplicates) + } + + @Test + fun equalCaptureTimesUseSourceFileAsCurrentRosterTieBreak() { + val capturedAt = "2026-08-11T00:00:00Z" + val latest = PlatoonSnapshot( + id = 1, + capturedAt = Instant.parse(capturedAt), + members = listOf(snapshotMember(1, "One"), snapshotMember(2, "Two")), + sourceFile = "import-20260811T000000Z-00000000000000000002.csv", + ) + val prepared = listOf( + prepared( + "import-20260811T000000Z-00000000000000000001.csv", + capturedAt, + member(99, "Historical"), + ), + prepared( + "import-20260811T000000Z-00000000000000000003.csv", + capturedAt, + member(1, "One"), + member(3, "Three"), + ), + ) + + val preview = CsvImportPreviewAnalyzer.analyze( + prepared = prepared, + duplicateFileNames = emptySet(), + existingMembers = listOf( + status(1, "One", active = true), + status(2, "Two", active = true), + ), + latestSnapshot = latest, + ) + + assertEquals(1, preview.historicalFiles) + assertEquals(1, preview.potentialJoins) + assertEquals(1, preview.potentialWithdrawals) + } + + private fun prepared( + fileName: String, + capturedAt: String, + vararg members: GuildMember, + ) = PlatoonCsvImportStore.PreparedImport( + encoded = fileName.toByteArray(), + snapshot = GuildMembersCsv.Snapshot(capturedAt, members.toList()), + capturedAt = Instant.parse(capturedAt), + fileName = fileName, + ) + + private fun member(uid: Int, name: String) = GuildMember( + uid = uid.toUInt(), + name = name, + level = 60u, + weeklyMerit = 0u, + totalMerit = 0u, + highScore = 0u, + totalScore = 0u, + lastLogin = 0u, + ) + + private fun snapshotMember(uid: Long, name: String) = SnapshotMember( + uid = uid, + name = name, + level = 60, + weeklyMerit = 0, + totalMerit = 0, + highScore = 0, + totalScore = 0, + lastLogin = 0, + ) + + private fun status(uid: Long, name: String, active: Boolean) = MemberStatus( + uid = uid, + name = name, + level = 60, + isActive = active, + firstSeenAt = Instant.EPOCH, + lastSeenAt = Instant.EPOCH, + note = "", + membershipPeriods = emptyList(), + ) +} diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt index a9deb30..04c76c5 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt @@ -27,6 +27,22 @@ class PlatoonCsvImportStoreTest { assertTrue(first.file.readText() == VALID_CSV) } + @Test + fun prepareValidatesWithoutPublishingUntilConfirmation() { + val directory = temporary.newFolder("preview") + val store = PlatoonCsvImportStore(directory) + + val prepared = store.prepare(ByteArrayInputStream(VALID_CSV.toByteArray())) + + assertEquals(VALID_CSV.toByteArray().size, prepared.byteCount) + assertEquals(Instant.parse("2026-07-19T19:29:33Z"), prepared.capturedAt) + assertEquals(emptyList(), directory.listFiles().orEmpty().map { it.name }) + val retained = store.retain(prepared) + assertTrue(retained.file.isFile) + assertFalse(retained.duplicate) + } + + @Test(expected = IllegalArgumentException::class) fun malformedCsvIsRejectedBeforeAnythingIsRetained() { val store = PlatoonCsvImportStore(temporary.newFolder("invalid")) diff --git a/app/src/test/java/dev/gf2log/app/management/StandardWeekSolverPropertyTest.kt b/app/src/test/java/dev/gf2log/app/management/StandardWeekSolverPropertyTest.kt new file mode 100644 index 0000000..f63dba6 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/StandardWeekSolverPropertyTest.kt @@ -0,0 +1,87 @@ +package dev.gf2log.app.management + +import java.time.LocalDate +import java.time.ZoneId +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.Assert.assertTrue + +class StandardWeekSolverPropertyTest { + private val zone = ZoneId.of("Asia/Seoul") + private val periodStart = LocalDate.of(2026, 7, 26) + + @Test + fun generatedAllocationsNeverProduceUnsupportedExactValues() { + val random = Random(0x2_2_0) + + repeat(128) { caseIndex -> + val allocation = List(6) { DAILY_VALUES[random.nextInt(DAILY_VALUES.size)] } + var cumulative = 0L + var total = 10_000L + var lastLogin = 0L + val snapshots = allocation.mapIndexed { index, merit -> + val day = periodStart.plusDays(index + 1L) + cumulative += merit + total += merit + if (merit > 0L) { + lastLogin = day.atTime(12, 0).atZone(zone).toEpochSecond() + } + PlatoonSnapshot( + id = index.toLong() + 1L, + capturedAt = day.atTime(23, 30).atZone(zone).toInstant(), + sourceFile = "generated-$caseIndex-$index.csv", + members = listOf( + SnapshotMember( + uid = 7L, + name = "Generated", + level = 1L, + weeklyMerit = cumulative, + totalMerit = total, + highScore = 0L, + totalScore = 0L, + lastLogin = lastLogin, + ), + ), + ) + } + + val asOf = periodStart.plusDays(7).atTime(6, 0).atZone(zone).toInstant() + val report = WeeklyReportBuilder.build( + referenceDay = periodStart.plusDays(3), + zoneId = zone, + snapshots = snapshots, + asOf = asOf, + ) + val cells = report.members.single().days.drop(1).take(6) + cells.zip(allocation).forEach { (cell, generated) -> + cell.meritDelta?.let { published -> + assertEquals("case $caseIndex on ${cell.gameDay}", generated, published) + } + if (cell.meritCertainty == MetricCertainty.EXACT) { + assertEquals("exact case $caseIndex on ${cell.gameDay}", generated, cell.meritDelta) + } + } + assertTrue( + "case $caseIndex published more merit than observed", + cells.mapNotNull { it.meritDelta }.sum() <= allocation.sum(), + ) + val reversed = WeeklyReportBuilder.build( + referenceDay = periodStart.plusDays(3), + zoneId = zone, + snapshots = snapshots.reversed(), + asOf = asOf, + ) + assertEquals( + "case $caseIndex", + cells.map { it.meritDelta to it.meritCertainty }, + reversed.members.single().days.drop(1).take(6) + .map { it.meritDelta to it.meritCertainty }, + ) + } + } + + private companion object { + val DAILY_VALUES = longArrayOf(0L, 50L, 90L) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzerTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzerTest.kt new file mode 100644 index 0000000..cfffe66 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzerTest.kt @@ -0,0 +1,75 @@ +package dev.gf2log.app.management + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyEvidenceAnalyzerTest { + @Test + fun explainsClosingBoundaryAndPartialLowerBound() { + val day = LocalDate.of(2026, 8, 9) + val exact = cell(day, merit = 40, DailyEvidence.ATTRIBUTED, closing = true) + val partial = cell(day, merit = 40, DailyEvidence.PARTIAL_DAY) + + val exactExplanation = WeeklyEvidenceAnalyzer.explainDaily( + exact, + WeeklyEvidenceAnalyzer.Metric.MERIT, + ) + assertEquals(MetricCertainty.EXACT, exactExplanation.certainty) + assertTrue( + WeeklyEvidenceAnalyzer.Fact.EXACT_CLOSING_BOUNDARY in exactExplanation.facts, + ) + + val partialExplanation = WeeklyEvidenceAnalyzer.explainDaily( + partial, + WeeklyEvidenceAnalyzer.Metric.MERIT, + ) + assertEquals(MetricCertainty.LOWER_BOUND, partialExplanation.certainty) + assertTrue( + WeeklyEvidenceAnalyzer.Fact.CONFIRMED_LOWER_BOUND in partialExplanation.facts, + ) + } + + @Test + fun healthCountsEvidenceWithoutInventingUnknownAnswers() { + val start = LocalDate.of(2026, 8, 3) + val days = (0L..6L).map(start::plusDays) + val cells = days.mapIndexed { index, day -> + if (index == 0) { + cell(day, merit = 40, DailyEvidence.PARTIAL_DAY) + } else { + cell(day, merit = null, DailyEvidence.NO_OBSERVATION) + } + } + val report = WeeklyReportBuilder.Report( + periodStart = start, + periodEnd = start.plusDays(6), + isGunsmokeWeek = false, + days = days, + members = listOf( + WeeklyReportBuilder.MemberRow(1, "Member", cells, totalMerit = 40, totalScore = 0), + ), + ) + + val health = WeeklyEvidenceAnalyzer.health(report) + assertEquals(1, health.observedDays) + assertEquals(0, health.exactMetrics) + assertEquals(1, health.lowerBoundMetrics) + assertEquals(20, health.unknownMetrics) + } + + private fun cell( + day: LocalDate, + merit: Long?, + evidence: DailyEvidence, + closing: Boolean = false, + ) = WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = merit, + scoreDelta = null, + inference = null, + evidence = evidence, + hasClosingBoundary = closing, + ) +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt new file mode 100644 index 0000000..7786366 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt @@ -0,0 +1,38 @@ +package dev.gf2log.app.management + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyReportStateHolderTest { + @Test + fun onlyLatestResumedRequestCanApply() { + val state = WeeklyReportStateHolder(LocalDate.of(2026, 8, 10)) + + state.onResume() + val first = state.newRenderRequest() + val second = state.newRenderRequest() + + assertFalse(state.canApply(first.generation)) + assertTrue(state.canApply(second.generation)) + + state.onPause() + assertFalse(state.canApply(second.generation)) + } + + @Test + fun navigationUsesAdjacentReportBoundaries() { + val state = WeeklyReportStateHolder(LocalDate.of(2026, 8, 10)) + + state.showPreviousWeek(LocalDate.of(2026, 8, 10)) + assertEquals(LocalDate.of(2026, 8, 9), state.referenceDay) + + state.showNextWeek(LocalDate.of(2026, 8, 16)) + assertEquals(LocalDate.of(2026, 8, 17), state.referenceDay) + + state.selectDate(LocalDate.of(2026, 7, 19)) + assertEquals(LocalDate.of(2026, 7, 19), state.referenceDay) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt new file mode 100644 index 0000000..2a91c3e --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt @@ -0,0 +1,79 @@ +package dev.gf2log.app.management + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyShareProjectionTest { + @Test + fun privateFieldsAreExcludedByDefaultAndCanBeIndividuallyEnabled() { + val report = report() + val defaultDocument = WeeklyShareProjection.build( + report, + report.members, + privateNotesByUid = mapOf(123L to "private"), + privacy = WeeklyShareProjection.Privacy(), + ) + assertEquals("Alice", defaultDocument.rows.single().member) + assertFalse(defaultDocument.includeNotes) + assertEquals(null, defaultDocument.rows.single().privateNote) + assertFalse(defaultDocument.rows.single().member.contains("123")) + + val privateDocument = WeeklyShareProjection.build( + report, + report.members, + privateNotesByUid = mapOf(123L to "private"), + privacy = WeeklyShareProjection.Privacy( + includeMemberNames = false, + includeUids = true, + includePrivateNotes = true, + ), + ) + assertEquals("#123", privateDocument.rows.single().member) + assertTrue(privateDocument.includeNotes) + assertEquals("private", privateDocument.rows.single().privateNote) + } + + @Test + fun hidingNamesAndUidsUsesStableAnonymousRowLabels() { + val report = report() + val document = WeeklyShareProjection.build( + report, + report.members, + emptyMap(), + WeeklyShareProjection.Privacy(includeMemberNames = false), + ) + assertEquals("Member 1", document.rows.single().member) + } + + private fun report(): WeeklyReportBuilder.Report { + val start = LocalDate.of(2026, 8, 10) + val days = (0L..6L).map(start::plusDays) + val cells = days.map { day -> + WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = null, + scoreDelta = null, + inference = null, + evidence = DailyEvidence.NO_OBSERVATION, + ) + } + return WeeklyReportBuilder.Report( + periodStart = start, + periodEnd = start.plusDays(6), + isGunsmokeWeek = false, + days = days, + members = listOf( + WeeklyReportBuilder.MemberRow( + uid = 123, + name = "Alice", + days = cells, + totalMerit = 0, + totalScore = 0, + ), + ), + ) + } +} diff --git a/build.gradle b/build.gradle index 0d9c0cc..02884c8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,4 @@ plugins { - id 'com.android.application' version '9.3.0' apply false - id 'com.android.library' version '9.3.0' apply false + id 'com.android.application' version '9.3.1' apply false + id 'com.android.library' version '9.3.1' apply false } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3ac618..90fef0d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -169,11 +169,12 @@ 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 known maintenance boundary. Schema, -migrations, and transactional evidence correlation stay together for v2.0.1 -to avoid a risky pre-release rewrite; future schema work should extract those -concerns behind the existing repository while retaining single-transaction -ingestion. +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. ## Parsed-packet history @@ -210,15 +211,43 @@ backed-up database, and keeping unrelated target-side files would allow a later screen startup to mutate the restored state. If any commit step fails, the retained cache is restored alongside the previous database and settings. + +User-selected roster CSV files are prepared and validated without retaining the +new selection. Before classification, crash-left retained evidence is reconciled +so the preview and confirmation start from one represented source set. +`CsvImportPreviewAnalyzer` compares the immutable candidates to that recovered +roster and presents their bounded impact. Only explicit confirmation retains +new files. Before that mutation, `CsvImportCheckpointManager` exports a private +checkpoint containing the database state and deterministic planned identities. +A provisional checkpoint keeps the preceding successful undo until the new +import seals. Undo quarantines only the planned identities and restores the +database under the repository's exclusive maintenance lock. Durable restore +state resumes interrupted quarantine/database replacement after process death; +ordinary failures restore both the evidence and the preceding undo checkpoint. +A post-import database digest refuses undo after any later Platoon mutation so +a one-level rollback cannot silently overwrite newer work. + +Weekly PNG export builds a privacy projection rather than screenshotting the +Activity. Names are included by default; UIDs and private notes are opt-in. +Rendering caps rows, dimensions, pixels, and note length, and Android shares +only a generated cache file through a non-exported `FileProvider`. Optional +Discord delivery accepts only canonical HTTPS `discord.com` incoming-webhook +URLs, stores the secret with Android Keystore AES-GCM, sends only the validated +CSV body after confirmation, refuses redirects, and bounds bytes and timeouts. + Weekly tables remain projections over persisted snapshots, activity facts, membership events, notes, and manual overrides. The repository owns available period discovery and report construction; Activities only request reports and -format them for display or CSV export. +format them for display or export. `StandardWeekSolver` and +`GunsmokeWeekSolver` own their respective counter inference, while +`WeeklyEvidenceAnalyzer` explains certainty without duplicating solver policy. `WeeklyReportActivity` loads one immutable projection on a serialized worker. -A generation token rejects results superseded by navigation or lifecycle -changes, and the activity adds nested member rows in small display-frame -batches. The Gunsmoke solver merges equivalent partial histories instead of +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 +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 Android input or publishing an unsupported estimate. Per-day state consensus diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef5..eddabd2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a351597..1e922f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew.bat b/gradlew.bat index 8508ef6..a51ec4f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,82 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserFuzzTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserFuzzTest.kt new file mode 100644 index 0000000..ead46f5 --- /dev/null +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserFuzzTest.kt @@ -0,0 +1,31 @@ +package dev.gf2log.protocol + +import kotlin.random.Random +import org.junit.Assert.assertTrue +import org.junit.Test + +class Gfl2StreamParserFuzzTest { + @Test + fun deterministicMalformedStreamsStayBoundedAndParserRemainsReusable() { + val random = Random(0x6f_32) + + repeat(256) { caseIndex -> + val parser = Gfl2StreamParser( + maximumBufferedBytes = 4_096, + maximumPendingPayloadBytes = 4_096, + maximumPendingContinuations = 8, + ) + var eventCount = 0 + repeat(24) { + val bytes = random.nextBytes(random.nextInt(0, 768)) + eventCount += parser.accept(bytes).size + } + eventCount += parser.finish().size + + assertTrue("case $caseIndex emitted an implausible event count", eventCount <= 24 * 768) + parser.reset() + assertTrue(parser.accept(byteArrayOf()).isEmpty()) + assertTrue(parser.finish().isEmpty()) + } + } +} diff --git a/settings.gradle b/settings.gradle index 7bf055e..06ee17a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -18,17 +18,17 @@ def forcedSecureVersions = [ 'org.jdom:jdom2:2.0.6.1', 'org.apache.commons:commons-lang3:3.20.0', 'org.apache.httpcomponents:httpclient:4.5.14', - 'io.netty:netty-common:4.2.16.Final', - 'io.netty:netty-buffer:4.2.16.Final', - 'io.netty:netty-codec:4.2.16.Final', - 'io.netty:netty-codec-http:4.2.16.Final', - 'io.netty:netty-codec-http2:4.2.16.Final', - 'io.netty:netty-codec-socks:4.2.16.Final', - 'io.netty:netty-handler:4.2.16.Final', - 'io.netty:netty-handler-proxy:4.2.16.Final', - 'io.netty:netty-resolver:4.2.16.Final', - 'io.netty:netty-transport:4.2.16.Final', - 'io.netty:netty-transport-native-unix-common:4.2.16.Final', + 'io.netty:netty-common:4.2.17.Final', + 'io.netty:netty-buffer:4.2.17.Final', + 'io.netty:netty-codec:4.2.17.Final', + 'io.netty:netty-codec-http:4.2.17.Final', + 'io.netty:netty-codec-http2:4.2.17.Final', + 'io.netty:netty-codec-socks:4.2.17.Final', + 'io.netty:netty-handler:4.2.17.Final', + 'io.netty:netty-handler-proxy:4.2.17.Final', + 'io.netty:netty-resolver:4.2.17.Final', + 'io.netty:netty-transport:4.2.17.Final', + 'io.netty:netty-transport-native-unix-common:4.2.17.Final', ] gradle.beforeProject { project ->