From 7a95e2b5035fd47a62206f8ba1c32a877ff13532 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Mon, 24 Aug 2026 23:09:23 +0900 Subject: [PATCH 1/4] feat: add isolated multi-platoon management --- .gitignore | 1 + CHANGELOG.md | 31 ++ README.md | 14 +- README_KR.md | 14 +- SECURITY.md | 18 +- app/build.gradle | 4 +- .../PlatoonBackupManagerIntegrationTest.kt | 25 +- .../PlatoonDatabaseIntegrationTest.kt | 51 +++ .../PlatoonProfileRegistryIntegrationTest.kt | 252 ++++++++++++ app/src/main/AndroidManifest.xml | 6 + .../main/java/dev/gf2log/app/MainActivity.kt | 29 +- .../java/dev/gf2log/app/OptionsActivity.kt | 112 ++++-- .../java/dev/gf2log/app/PlatoonActivity.kt | 10 + .../dev/gf2log/app/PlatoonProfileSelector.kt | 80 ++++ .../dev/gf2log/app/WeeklyReportActivity.kt | 52 ++- .../app/capture/BoundedFlowPayloadBuffer.kt | 42 ++ .../gf2log/app/capture/CaptureFlowMetadata.kt | 51 ++- .../app/capture/CaptureFlowStateCleanup.kt | 13 + .../gf2log/app/capture/CaptureVpnService.kt | 272 ++++++++----- .../app/capture/PlatoonCaptureSession.kt | 69 ++++ .../app/capture/PlatoonProfilePolicy.kt | 14 + .../app/capture/ScopedCaptureChecklist.kt | 34 ++ .../gf2log/app/management/BackupArchive.kt | 154 ++++++- .../app/management/BackupFormatPolicy.kt | 7 +- .../management/CsvImportCheckpointManager.kt | 44 +- .../management/MembershipConsistencyPolicy.kt | 8 +- .../app/management/PlatoonBackupManager.kt | 380 ++++++++++++++---- .../gf2log/app/management/PlatoonDatabase.kt | 78 +++- .../gf2log/app/management/PlatoonProfile.kt | 340 ++++++++++++++++ .../app/management/PlatoonRepository.kt | 72 ++-- .../management/WeeklyMemberNameProjection.kt | 9 + .../gf2log/app/management/WeeklyNotePolicy.kt | 15 + .../gf2log/app/settings/AppSettingsStore.kt | 45 +++ .../settings/ClientServerRegionPreferences.kt | 52 +++ .../app/settings/GameTimeZonePreferences.kt | 69 +++- .../app/settings/MemberOrderPreferences.kt | 37 +- .../app/settings/WeeklyCutlinePreferences.kt | 69 +++- app/src/main/res/values-ko/strings.xml | 19 +- app/src/main/res/values/strings.xml | 19 +- .../capture/BoundedFlowPayloadBufferTest.kt | 31 ++ .../app/capture/CaptureFlowOwnerPolicyTest.kt | 32 ++ .../capture/CaptureFlowStateCleanupTest.kt | 29 ++ .../app/capture/PlatoonProfilePolicyTest.kt | 21 + .../app/capture/ScopedCaptureChecklistTest.kt | 38 ++ .../app/management/BackupArchiveTest.kt | 32 ++ .../app/management/BackupFormatPolicyTest.kt | 8 + .../MembershipConsistencyPolicyTest.kt | 24 ++ .../management/PlatoonProfileIdentityTest.kt | 62 +++ .../WeeklyMemberNameProjectionTest.kt | 18 + .../app/management/WeeklyNotePolicyTest.kt | 15 + .../settings/ClientServerRegionPolicyTest.kt | 22 + docs/ARCHITECTURE.md | 34 +- 52 files changed, 2644 insertions(+), 333 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt create mode 100644 app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/WeeklyMemberNameProjection.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt create mode 100644 app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureFlowOwnerPolicyTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/PlatoonProfileIdentityTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/WeeklyMemberNameProjectionTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/settings/ClientServerRegionPolicyTest.kt diff --git a/.gitignore b/.gitignore index 56bd391..9063a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ Thumbs.db .DS_Store # Local device and UI verification artifacts (repository root only) +/artifacts/ /*.xml /*.db /*.db-* diff --git a/CHANGELOG.md b/CHANGELOG.md index b52c11f..cdc7856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to mobileGF2logger are documented here. +## 2.4.0 - 2026-08-24 + +### Added + +- Detect each Platoon from the authoritative `21905` identity plus Android's + supported-client flow ownership, and keep separate HaoPlay/Darkwinter and + server-region profiles in the Home, Platoon, Weekly, and Settings selectors. +- Add independently configurable HaoPlay and Darkwinter capture-server + presets, scoped database/CSV/checkpoint/report settings, and profile-aware + format-v3 `.gf2backup` archives. + +### Changed + +- Quarantine up to 32 decoded payloads per TCP flow until both its supported + Android client and valid Platoon identity are known; unverified flows never + enter management storage. +- Keep one-time-capture completion evidence isolated per detected Platoon so + two clients cannot accidentally complete one checklist. +- Preserve v2.3.x data as an unmoved legacy profile while new Platoons use + deterministic private databases and retained-evidence directories. + +### Fixed + +- Resolve all six review findings from v2.3.3: clean flow metadata without a + parser, order known same-day joins by instant, bound manual weekly notes, + preserve captured member names, replace timezone-derived history atomically, + and accept `21905` checklist evidence only after identity validation. +- Bound the profile registry, profile metadata, and pre-identity flow buffer; + reject invalid restores before metadata changes, preserve the selected import + scope through preview/apply, and restore the matching client-region routing. + ## 2.3.3 - 2026-08-24 ### Added diff --git a/README.md b/README.md index bf3a398..6949589 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,11 @@ never stored. ## Features - Captures the mandatory Platoon Profile (`21905`), Members (`21917`), Activity (`21935`), and Updates (`21960`) responses without a computer or root access. +- Automatically separates detected Platoons by supported Android client, + selected server region, and authoritative Platoon ID; switch the active + isolated profile from Home, Platoon, Weekly, or Settings. - Tracks active and withdrawn members, non-overlapping repeat membership periods, exact Updates timestamps, editable nicknames, and private notes. -- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the 05:00 game reset, with cut-off points and manual correction for missing data. +- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the selected server's daily reset, with cut-off points and manual correction for missing data. - Offers One-time Capture that tracks the four useful Platoon payloads and stops automatically when the checklist is complete. - Explains every weekly cell on tap and summarizes missing or uncertain evidence in an Evidence Health panel. - Keeps up to 15 complete automatic revisions per weekly table so an earlier projection and its displayed member context can be previewed and restored after an accidental import. @@ -26,7 +29,7 @@ never stored. - Saves or shares a weekly PNG with opt-in controls for names, UIDs, and private notes. - Can send a validated original CSV to an optional user-owned Discord incoming webhook after confirmation. - Stores the latest 100 parsed packets and up to 50 saved packets, with table and raw views, copy, export, selection, and deletion. -- Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and complete `.gf2backup` export/restore. +- Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and profile-aware `.gf2backup` export/restore that leaves other Platoons unchanged. - Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action. - Supports English and Korean, System/Light/Dark themes, explicit Darkwinter/HaoPlay server-region reset presets converted to the phone timezone, and a persistent manual game-timezone fallback. - Registers the HaoPlay (`com.haoplay.game.and.exilium`) and Darkwinter @@ -45,6 +48,13 @@ uid,name,level,weeklyMerit,totalMerit,highScore,totalScore,lastLogin,logTime 4. Enter **Platoon(서클)** and open **Updates(동향)** and **Members(멤버)**. 5. Return to GF2logger to review the captured packets and Platoon data. +On Android 10 and newer, Android identifies which supported game owns each +captured connection. Android 8–9 can safely attribute management data only when +exactly one supported client is installed; with both clients installed, +unattributed management payloads are deliberately not imported. Because the +plaintext protocol does not expose a trustworthy server identifier, select the +correct HaoPlay and Darkwinter server in Settings before first capture. + 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. ## Reference diff --git a/README_KR.md b/README_KR.md index 437555a..7faf1f3 100644 --- a/README_KR.md +++ b/README_KR.md @@ -11,8 +11,11 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android ## 기능 - 컴퓨터나 루트 권한 없이 필수 서클 프로필(`21905`), 멤버(`21917`), 활동(`21935`), 동향(`21960`) 응답을 캡처합니다. +- 지원 Android 클라이언트, 선택한 서버 지역, 서클 ID를 기준으로 감지한 + 서클의 데이터를 자동 분리하며, 홈·서클·주간·설정 화면에서 활성 + 서클을 전환할 수 있습니다. - 가입 및 탈퇴 멤버, 서로 겹치지 않는 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다. -- 05:00 게임 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다. +- 선택한 서버의 일일 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다. - 한 번만 캡처는 네 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다. - 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다. - 주간 표마다 표시 당시의 멤버 문맥까지 포함한 전체 자동 기록을 최대 15개 보관하여 잘못 가져온 뒤에도 이전 표를 미리 보고 복원할 수 있습니다. @@ -20,7 +23,7 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다. - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. -- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 서클 관리 데이터 백업 및 복원을 지원합니다. +- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 다른 서클을 변경하지 않는 프로필별 백업 및 복원을 지원합니다. - 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. - 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. Darkwinter/HaoPlay 서버 지역별 초기화 시각을 기기 시간대로 환산하며, 예외 상황에는 게임 시간대를 수동으로 설정할 수 있습니다. - HaoPlay(`com.haoplay.game.and.exilium`)와 Darkwinter(`com.Sunborn.SnqxExilium.Glo`) Android 클라이언트를 별도의 VPN 대상으로 등록합니다. @@ -38,6 +41,13 @@ uid,name,level,weeklyMerit,totalMerit,highScore,totalScore,lastLogin,logTime 4. **서클(Platoon)**에 들어가 **동향(Updates)**과 **멤버(Members)**를 엽니다. 5. GF2logger로 돌아와 캡처한 패킷과 서클 데이터를 확인합니다. +Android 10 이상에서는 Android가 각 연결을 소유한 지원 게임을 +식별합니다. Android 8–9에서는 지원 클라이언트가 하나만 설치된 경우에만 +안전하게 관리 데이터를 분류할 수 있으며, 두 클라이언트가 모두 설치된 +상태의 미식별 데이터는 가져오지 않습니다. 평문 프로토콜에는 신뢰할 수 +있는 서버 식별자가 없으므로 첫 캡처 전에 설정에서 HaoPlay와 Darkwinter의 +서버를 올바르게 선택하세요. + 앱은 파싱 내역, 관리 데이터, 생성한 CSV 파일을 기기의 비공개 저장소에 보관합니다. TLS, 인증서 고정 또는 안티치트 체계를 우회하지 않으며 게임 트래픽을 변경하지 않습니다. 서버 응답에는 최근의 일부 이력만 포함될 수 있으므로, 누락된 과거 가입 이력은 직접 추가할 수 있습니다. ## 참고 diff --git a/SECURITY.md b/SECURITY.md index 7f8673c..d84e951 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,8 @@ Security fixes are made on the latest published release and the current | Version | Supported | | --- | --- | -| 2.3.x | Yes | -| 2.2.x and earlier | No | +| 2.4.x | Yes | +| 2.3.x and earlier | No | ## Reporting a vulnerability @@ -44,12 +44,16 @@ plaintext protocol. Parsed data and backups therefore provide local integrity and management convenience, not cryptographic proof that a remote payload is genuine. -On Android 10 and newer, the active VPN may ask Android for the UID owning an -original connection tuple and map that UID only to the fixed supported package +On Android 10 and newer, the active VPN asks Android for the UID owning an +original connection tuple and maps that UID only to the fixed supported package IDs. Remote IP addresses and DNS/SNI labels are diagnostic hints, not trusted -client or server identities. Payload `21905` supplies a bounded Platoon identity -for its own decoded flow, but multi-Platoon persistence remains disabled until -every database, CSV, import, backup, and UI path can enforce the same scope. +client or server identities. Android 8–9 falls back only when exactly one +supported client is installed; ambiguous flows remain quarantined. Payload +`21905` supplies a bounded Platoon identity for its own decoded flow. Only the +composite of verified client, user-selected server region, and Platoon ID may +select an isolated management database, retained CSV directory, checkpoint, +weekly settings, or backup scope. Pre-identity payloads, registered profiles, +and profile metadata are independently bounded. Exports and Discord sends are explicit user actions that move selected data out of Android private storage. Backups are checksummed and strictly validated but diff --git a/app/build.gradle b/app/build.gradle index 5b1fdda..233ed80 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20303 - versionName = '2.3.3' + versionCode = 20400 + versionName = '2.4.0' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { 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 10c0120..5cb8ffc 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -735,12 +735,28 @@ class PlatoonBackupManagerIntegrationTest { } private fun clearState() { - runCatching { - PlatoonRepository.withExclusiveDatabase { - context.deleteDatabase(PlatoonSchema.DATABASE_NAME) + context.databaseList() + .filter { + it == PlatoonSchema.DATABASE_NAME || + it.matches(Regex("platoon-[0-9a-f]{32}\\.db")) + } + .forEach { databaseName -> + val scope = PlatoonStorageScope.fromDatabaseName(databaseName) + runCatching { + PlatoonRepository.withExclusiveDatabase(scope) { + context.deleteDatabase(databaseName) + } + } } - } context.getSharedPreferences(USER_SETTINGS, Context.MODE_PRIVATE).edit().clear().commit() + listOf( + "platoon_profiles", + "platoon_member_order", + "platoon_weekly_cutlines", + "platoon_timezones", + ).forEach { preferences -> + context.getSharedPreferences(preferences, Context.MODE_PRIVATE).edit().clear().commit() + } FilePaths.restoreDirectory(context).deleteRecursively() FilePaths.restoreTransactionDirectory(context).deleteRecursively() FilePaths.retainedCsvDirectory(context).deleteRecursively() @@ -748,6 +764,7 @@ class PlatoonBackupManagerIntegrationTest { FilePaths.csvCheckpointDirectory(context).deleteRecursively() FilePaths.csvCheckpointStagingDirectory(context).deleteRecursively() FilePaths.csvCheckpointPreviousDirectory(context).deleteRecursively() + java.io.File(context.filesDir, "platoons").deleteRecursively() } private object FilePaths { diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt index f1b152e..32e207f 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -11,6 +11,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -1160,6 +1161,56 @@ class PlatoonDatabaseIntegrationTest { assertFalse(database.listWeeklyReportHistory(period).any { it.active }) } + @Test + fun failedAtomicHistoryReplacementPreservesEveryPreviousRevision() { + val originalPeriod = LocalDate.of(2026, 8, 16).toEpochDay() + database.recordWeeklyReportHistory( + periodStartEpochDay = originalPeriod, + recordedAt = Instant.ofEpochMilli(1L), + fingerprint = "1".padStart(64, '0'), + payload = byteArrayOf(1), + clearActiveOnChange = true, + ) + val rejectedPeriod = originalPeriod + 7 + database.writableDatabase.execSQL( + "CREATE TRIGGER reject_history BEFORE INSERT ON weekly_report_history " + + "WHEN NEW.period_start = $rejectedPeriod " + + "BEGIN SELECT RAISE(ABORT, 'test rejection'); END", + ) + + assertThrows(android.database.SQLException::class.java) { + database.replaceWeeklyReportHistory( + listOf( + WeeklyReportHistoryReplacement( + periodStartEpochDay = rejectedPeriod, + recordedAt = Instant.ofEpochMilli(2L), + fingerprint = "2".padStart(64, '0'), + payload = byteArrayOf(2), + ), + ), + ) + } + + assertEquals(1, database.listWeeklyReportHistory(originalPeriod).size) + assertTrue(database.listWeeklyReportHistory(rejectedPeriod).isEmpty()) + } + + @Test + fun weeklyNoteLimitRejectsDataThatHistoryCannotRepresent() { + val period = LocalDate.of(2026, 8, 16).toEpochDay() + repeat(WeeklyNotePolicy.MAX_MANUAL_NOTES_PER_WEEK) { index -> + database.addWeeklyNote(period, period + index % 7, "Note $index") + } + + assertThrows(IllegalArgumentException::class.java) { + database.addWeeklyNote(period, period, "One too many") + } + assertEquals( + WeeklyNotePolicy.MAX_MANUAL_NOTES_PER_WEEK, + database.listWeeklyNotes(period).count { !it.isAutomatic }, + ) + } + private fun update(kind: Long, at: Instant, uid: Long, name: String) = PlatoonUpdateObservation( kind = kind, diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt new file mode 100644 index 0000000..6c7f785 --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -0,0 +1,252 @@ +package dev.gf2log.app.management + +import androidx.test.core.app.ApplicationProvider +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.protocol.model.PlatoonProfileData +import dev.gf2log.protocol.model.GuildMember +import java.io.File +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.time.Instant +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class PlatoonProfileRegistryIntegrationTest { + private val context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() = clearState() + + @After + fun tearDown() = clearState() + + @Test + fun emptyDatabaseIsNotRegisteredAsLegacyData() { + PlatoonDatabase(context).use { database -> + database.recordWeeklyReportHistory( + periodStartEpochDay = 0L, + recordedAt = Instant.EPOCH, + fingerprint = "0".repeat(64), + payload = byteArrayOf(1), + clearActiveOnChange = false, + ) + } + + val registry = PlatoonProfileRegistry(context) + + assertTrue(registry.ensureInitialized().isEmpty()) + assertEquals(PlatoonProfileIdentity.LEGACY_STORAGE_ID, registry.activeScope().storageId) + } + + @Test + fun existingDatabaseWithManagementDataIsRegisteredWithoutMovingIt() { + val legacyDatabase = context.getDatabasePath(PlatoonSchema.DATABASE_NAME) + PlatoonRepository(context).ingest( + Instant.parse("2026-08-24T00:00:00Z"), + listOf(member(1u, "Legacy member")), + "legacy.csv", + ) + + val registry = PlatoonProfileRegistry(context) + val profile = registry.ensureInitialized().single() + + assertTrue(profile.legacy) + assertEquals(PlatoonSchema.DATABASE_NAME, registry.activeScope().databaseName) + assertTrue(legacyDatabase.isFile) + } + + @Test + fun publisherAndRegionKeepEqualPlatoonIdsInDifferentScopes() { + val registry = PlatoonProfileRegistry(context) + val data = PlatoonProfileData(101817u, "Owls", listOf(1u), listOf(2u)) + + val haoPlay = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + data, + Instant.parse("2026-08-24T00:00:00Z"), + ) + val darkwinter = registry.upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + data, + Instant.parse("2026-08-24T00:01:00Z"), + ) + + assertNotEquals(haoPlay.storageId, darkwinter.storageId) + assertNotEquals( + PlatoonStorageScope(haoPlay.storageId).databaseName, + PlatoonStorageScope(darkwinter.storageId).databaseName, + ) + assertEquals(2, registry.list().size) + assertTrue(registry.setActive(darkwinter.storageId)) + assertEquals(darkwinter.storageId, registry.activeScope().storageId) + assertFalse(registry.setActive("0".repeat(32))) + } + + @Test + fun equalMemberUidsRemainIsolatedAcrossProfileDatabases() { + val registry = PlatoonProfileRegistry(context) + val identity = PlatoonProfileData(77u, "First", emptyList(), emptyList()) + val first = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + identity, + ) + val second = registry.upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + identity.copy(platoonName = "Second"), + ) + val firstRepository = PlatoonRepository(context, PlatoonStorageScope(first.storageId)) + val secondRepository = PlatoonRepository(context, PlatoonStorageScope(second.storageId)) + + firstRepository.ingest( + Instant.parse("2026-08-24T01:00:00Z"), + listOf(member(9u, "HaoPlay member")), + "first.csv", + ) + secondRepository.ingest( + Instant.parse("2026-08-24T01:00:00Z"), + listOf(member(9u, "Darkwinter member")), + "second.csv", + ) + + assertEquals("HaoPlay member", firstRepository.listMemberStatuses().single().name) + assertEquals("Darkwinter member", secondRepository.listMemberStatuses().single().name) + } + + @Test + fun registryRejectsUnboundedNewProfilesAndCanRemoveInactiveMetadata() { + val registry = PlatoonProfileRegistry(context) + val profiles = (1..PlatoonProfileRegistry.MAX_PROFILES).map { id -> + registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(id.toUInt(), "Platoon $id", emptyList(), emptyList()), + ) + } + + val overflow = runCatching { + registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(999u, "Overflow", emptyList(), emptyList()), + ) + } + + assertTrue(overflow.isFailure) + assertTrue(registry.removeIfInactive(profiles.last().storageId)) + assertEquals(PlatoonProfileRegistry.MAX_PROFILES - 1, registry.list().size) + assertFalse(registry.removeIfInactive(profiles.first().storageId)) + } + + @Test + fun scopedCompleteBackupRecreatesItsProfileOnAFreshRegistry() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_JAPAN, + PlatoonProfileData(101817u, "Owls", emptyList(), emptyList()), + ) + registry.setActive(profile.storageId) + PlatoonRepository(context, PlatoonStorageScope(profile.storageId)).ingest( + Instant.parse("2026-08-24T02:00:00Z"), + listOf(member(10u, "Restored member")), + "restore.csv", + ) + val archive = ByteArrayOutputStream().also { output -> + PlatoonBackupManager(context).exportFull(output) + }.toByteArray() + + PlatoonRepository.withExclusiveDatabase(PlatoonStorageScope(profile.storageId)) { + context.deleteDatabase(PlatoonStorageScope(profile.storageId).databaseName) + } + context.getSharedPreferences("platoon_profiles", android.content.Context.MODE_PRIVATE) + .edit().clear().commit() + File(context.filesDir, "platoons").deleteRecursively() + + PlatoonBackupManager(context).restoreFull(ByteArrayInputStream(archive)) + + val restoredRegistry = PlatoonProfileRegistry(context) + assertEquals(profile.storageId, restoredRegistry.activeScope().storageId) + assertEquals( + GameServerRegion.HAOPLAY_JAPAN, + ClientServerRegionPreferences(context).get(SupportedGamePackages.HAOPLAY), + ) + assertEquals( + "Restored member", + PlatoonRepository(context, restoredRegistry.activeScope()) + .listMemberStatuses() + .single() + .name, + ) + } + + @Test + fun rejectedScopedBackupDoesNotAlterExistingProfileMetadata() { + val registry = PlatoonProfileRegistry(context) + val existing = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101817u, "Original name", emptyList(), emptyList()), + ) + assertTrue(registry.setActive(existing.storageId)) + val invalidDatabase = File(context.cacheDir, "invalid-profile-restore.db").apply { + writeText("not a SQLite database") + } + val archive = ByteArrayOutputStream().also { output -> + BackupArchive.write( + output, + invalidDatabase, + settings = null, + profile = existing.copy(platoonName = "Untrusted replacement"), + ) + }.toByteArray() + + val failure = runCatching { + PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + } + + assertTrue(failure.isFailure) + assertEquals("Original name", registry.find(existing.storageId)?.platoonName) + invalidDatabase.delete() + } + + private fun member(uid: UInt, name: String) = GuildMember( + uid = uid, + name = name, + level = 1u, + weeklyMerit = 0u, + totalMerit = 0u, + highScore = 0u, + totalScore = 0u, + lastLogin = 0u, + ) + + private fun clearState() { + context.databaseList() + .filter { it == PlatoonSchema.DATABASE_NAME || it.matches(Regex("platoon-[0-9a-f]{32}\\.db")) } + .forEach { databaseName -> + val scope = PlatoonStorageScope.fromDatabaseName(databaseName) + PlatoonRepository.withExclusiveDatabase(scope) { + context.deleteDatabase(databaseName) + } + } + context.getSharedPreferences("platoon_profiles", android.content.Context.MODE_PRIVATE) + .edit().clear().commit() + context.getSharedPreferences( + ClientServerRegionPreferences.PREFERENCES, + android.content.Context.MODE_PRIVATE, + ).edit().clear().commit() + File(context.filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY).deleteRecursively() + File(context.filesDir, "platoons").deleteRecursively() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 77f5fec..7e6f1a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + + + diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index cb1ba29..0f0673c 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -37,6 +37,8 @@ import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.CsvImportPreviewAnalyzer import dev.gf2log.app.management.PlatoonCsvImportStore import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.management.BackupFileName import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.Gfl2PayloadDecoder @@ -248,6 +250,13 @@ class MainActivity : LocalizedActivity() { textSize = 13f typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView( + PlatoonProfileSelector.button(this@MainActivity, compact = true), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) }, matchWidth()) captureStateText = TextView(context).apply { textSize = 22f @@ -635,15 +644,16 @@ class MainActivity : LocalizedActivity() { statusText.text = getString(R.string.stop_capture_before_csv_import) return } + val storageScope = PlatoonProfileRegistry(this).activeScope() statusText.text = getString(R.string.csv_import_preparing_preview) fileIoExecutor.execute { val result = runCatching { // Recover an interrupted prior import before reading the preview baseline. - CsvImportCheckpointManager(this) + CsvImportCheckpointManager(this, storageScope) require(sources.size <= MAX_CSV_IMPORT_FILES) { "Too many Platoon CSV files were selected" } - val directory = File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + val directory = storageScope.retainedCsvDirectory(this) val store = PlatoonCsvImportStore(directory) val selected = ArrayList(sources.size) var selectedBytes = 0L @@ -658,7 +668,7 @@ class MainActivity : LocalizedActivity() { selected += prepared } val unique = selected.distinctBy(PlatoonCsvImportStore.PreparedImport::fileName) - val repository = PlatoonRepository(this) + val repository = PlatoonRepository(this, storageScope) repository.reconcileRetainedCsvFiles(directory) val duplicateNames = CsvImportPreviewAnalyzer.duplicateFileNames( prepared = unique, @@ -675,7 +685,7 @@ class MainActivity : LocalizedActivity() { duplicateFiles = analyzed.duplicateFiles + selected.size - unique.size, totalBytes = selectedBytes, ) - PendingCsvImport(unique, duplicateNames, preview) + PendingCsvImport(storageScope, unique, duplicateNames, preview) } statusHandler.post { if (isFinishing || isDestroyed) return@post @@ -739,9 +749,9 @@ class MainActivity : LocalizedActivity() { pendingCsvImport = null statusText.text = getString(R.string.csv_import_applying) fileIoExecutor.execute { - val directory = File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + val directory = pending.storageScope.retainedCsvDirectory(this) val store = PlatoonCsvImportStore(directory) - val checkpoint = CsvImportCheckpointManager(this) + val checkpoint = CsvImportCheckpointManager(this, pending.storageScope) var checkpointCreated = false val result = runCatching { val plannedNames = pending.prepared @@ -756,7 +766,8 @@ class MainActivity : LocalizedActivity() { if (it.duplicate) duplicates += 1 else retained += 1 } } - val imported = PlatoonRepository(this).reconcileRetainedCsvFiles(directory) + val imported = PlatoonRepository(this, pending.storageScope) + .reconcileRetainedCsvFiles(directory) checkpoint.seal() CsvImportSummary(retained, duplicates, imported) }.recoverCatching { failure -> @@ -836,7 +847,8 @@ class MainActivity : LocalizedActivity() { @Suppress("DEPRECATION") private fun exportLatestPlatoonCsv() { - val directory = File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + val directory = PlatoonProfileRegistry(this).activeScope() + .retainedCsvDirectory(this) val latest = PlatoonCsvImportStore.latestRetainedFile(directory) if (latest == null) { statusText.text = getString(R.string.status_no_platoon_csv) @@ -1052,6 +1064,7 @@ class MainActivity : LocalizedActivity() { ) private data class PendingCsvImport( + val storageScope: PlatoonStorageScope, 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 c6da9bb..1fd4f81 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -26,13 +26,16 @@ import android.widget.Toast import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.settings.CapturePreferences import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.capture.CaptureDiagnosticsStore import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.management.BackupFileName import dev.gf2log.app.management.InvalidBackupException import dev.gf2log.app.management.PlatoonBackupManager +import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.protocol.Gfl2PayloadDecoder import java.time.ZoneId @@ -112,6 +115,13 @@ class OptionsActivity : LocalizedActivity() { setTypeface(typeface, Typeface.BOLD) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) }, matchWidth()) + addView( + PlatoonProfileSelector.button(this@OptionsActivity), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(6) }, + ) addView(TextView(context).apply { text = getString(R.string.language) @@ -177,6 +187,20 @@ class OptionsActivity : LocalizedActivity() { icon = R.drawable.ic_calendar, onClick = ::chooseGameServerRegion, ), matchWidth()) + addView(ModernUi.listRow( + context = context, + title = getString(R.string.haoplay_capture_region), + detail = regionLabel(ClientServerRegionPreferences(context).get(SupportedGamePackages.HAOPLAY)), + icon = R.drawable.ic_group, + onClick = { chooseClientServerRegion(SupportedGamePackages.HAOPLAY) }, + ), matchWidth()) + addView(ModernUi.listRow( + context = context, + title = getString(R.string.darkwinter_capture_region), + detail = regionLabel(ClientServerRegionPreferences(context).get(SupportedGamePackages.DARKWINTER)), + icon = R.drawable.ic_group, + onClick = { chooseClientServerRegion(SupportedGamePackages.DARKWINTER) }, + ), matchWidth()) if (resetRegion == GameServerRegion.MANUAL) { addView(ModernUi.listRow( context = context, @@ -479,36 +503,19 @@ class OptionsActivity : LocalizedActivity() { } private fun chooseGameTimeZone() { + val storageScope = PlatoonProfileRegistry(this).activeScope() val zones = ZoneId.getAvailableZoneIds().sorted() - val current = GameTimeZonePreferences.get(this).id + val current = GameTimeZonePreferences.get(this, storageScope.storageId).id AlertDialog.Builder(this) .setTitle(R.string.game_timezone) .setSingleChoiceItems(zones.toTypedArray(), zones.indexOf(current)) { dialog, which -> val selected = ZoneId.of(zones[which]) - if (selected == GameTimeZonePreferences.get(this)) { + if (selected == GameTimeZonePreferences.get(this, storageScope.storageId)) { dialog.dismiss() return@setSingleChoiceItems } dialog.dismiss() - fileIoExecutor.execute { - val result = runCatching { - GameTimeZonePreferences.set(this, selected) - PlatoonRepository(this).rebuildWeeklyHistoryForTimeZoneChange() - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - Toast.makeText( - this, - if (result.isSuccess) { - R.string.game_timezone_updated - } else { - R.string.game_timezone_update_failed - }, - Toast.LENGTH_SHORT, - ).show() - recreate() - } - } + updateGameTimeZone(storageScope, GameServerRegion.MANUAL, selected) } .setNegativeButton(android.R.string.cancel, null) .show() @@ -535,11 +542,70 @@ class OptionsActivity : LocalizedActivity() { .show() } + private fun chooseClientServerRegion(packageName: String) { + val preferences = ClientServerRegionPreferences(this) + val regions = preferences.allowed(packageName) + val current = preferences.get(packageName) + AlertDialog.Builder(this) + .setTitle( + if (packageName == SupportedGamePackages.HAOPLAY) { + R.string.haoplay_capture_region + } else { + R.string.darkwinter_capture_region + }, + ) + .setSingleChoiceItems( + regions.map(::regionLabel).toTypedArray(), + regions.indexOf(current), + ) { dialog, which -> + preferences.set(packageName, regions[which]) + dialog.dismiss() + recreate() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun updateResetRegion(region: GameServerRegion) { + val storageScope = PlatoonProfileRegistry(this).activeScope() + updateGameTimeZone(storageScope, region, requireNotNull(region.serverZone)) + } + + private fun updateGameTimeZone( + storageScope: PlatoonStorageScope, + region: GameServerRegion, + zoneId: ZoneId, + ) { fileIoExecutor.execute { + val previousRegion = GameTimeZonePreferences.region(this, storageScope.storageId) + val previousZone = GameTimeZonePreferences.get(this, storageScope.storageId) val result = runCatching { - GameTimeZonePreferences.setRegion(this, region) - PlatoonRepository(this).rebuildWeeklyHistoryForTimeZoneChange() + if (region == GameServerRegion.MANUAL) { + GameTimeZonePreferences.set(this, zoneId, storageScope.storageId) + } else { + GameTimeZonePreferences.setRegion(this, region, storageScope.storageId) + } + try { + PlatoonRepository(this, storageScope) + .rebuildWeeklyHistoryForTimeZoneChange(zoneId) + } catch (error: Exception) { + runCatching { + if (previousRegion == GameServerRegion.MANUAL) { + GameTimeZonePreferences.set( + this, + previousZone, + storageScope.storageId, + ) + } else { + GameTimeZonePreferences.setRegion( + this, + previousRegion, + storageScope.storageId, + ) + } + }.exceptionOrNull()?.let(error::addSuppressed) + throw error + } } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index f29de53..3bf62e9 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -100,6 +100,16 @@ class PlatoonActivity : LocalizedActivity() { textSize = 24f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) + addView( + PlatoonProfileSelector.button(this@PlatoonActivity), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = dp(8) + bottomMargin = dp(4) + }, + ) summary = TextView(context).apply { textSize = 14f setTextColor(getColor(R.color.text_secondary)) diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt new file mode 100644 index 0000000..ba7760f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -0,0 +1,80 @@ +package dev.gf2log.app + +import android.app.AlertDialog +import android.app.Activity +import android.text.TextUtils +import android.widget.Button +import android.widget.Toast +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.settings.GameServerRegion + +/** Shared, presentation-only selector for the active isolated Platoon scope. */ +internal object PlatoonProfileSelector { + fun button(activity: Activity, compact: Boolean = false): Button { + val registry = PlatoonProfileRegistry(activity) + return Button(activity).apply { + text = (registry.active()?.let { label(activity, it) } + ?: activity.getString(R.string.no_platoon_detected)) + " ▾" + contentDescription = activity.getString(R.string.select_platoon) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + if (compact) { + textSize = 11f + minHeight = 0 + minimumHeight = 0 + maxWidth = dp(activity, 210) + setPadding(dp(activity, 10), dp(activity, 4), dp(activity, 10), dp(activity, 4)) + background = ModernUi.panelBackground(activity).apply { + setStroke(dp(activity, 1), activity.getColor(R.color.outline)) + } + } else { + useNavigationActionStyle() + } + setOnClickListener { show(activity, registry) } + } + } + + private fun label(activity: Activity, profile: PlatoonProfile): String = if (profile.legacy) { + activity.getString(R.string.existing_platoon_data) + } else { + "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + + "${profile.platoonName} / ${profile.platoonId}" + } + + private fun show(activity: Activity, registry: PlatoonProfileRegistry) { + val profiles = registry.list() + if (profiles.isEmpty()) { + Toast.makeText(activity, R.string.no_platoon_detected_detail, Toast.LENGTH_SHORT).show() + return + } + val activeId = registry.active()?.storageId + AlertDialog.Builder(activity) + .setTitle(R.string.select_platoon) + .setSingleChoiceItems( + profiles.map { label(activity, it) }.toTypedArray(), + profiles.indexOfFirst { it.storageId == activeId }, + ) { dialog, which -> + val selected = profiles[which] + dialog.dismiss() + if (selected.storageId != activeId && registry.setActive(selected.storageId)) { + activity.recreate() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun regionCode(region: GameServerRegion): String = when (region) { + GameServerRegion.MANUAL -> "Manual" + GameServerRegion.DARKWINTER_GLOBAL -> "GL" + GameServerRegion.DARKWINTER_CHINA -> "CN" + GameServerRegion.HAOPLAY_GLOBAL -> "GL" + GameServerRegion.HAOPLAY_JAPAN -> "JP" + GameServerRegion.HAOPLAY_KOREA -> "KR" + GameServerRegion.HAOPLAY_ASIA -> "ASIA" + } + + private fun dp(activity: Activity, value: Int): Int = + (value * activity.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 30d471c..8ef585e 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -44,11 +44,13 @@ 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.WeeklyNoteLimitException import dev.gf2log.app.management.WeeklyReportBuilder import dev.gf2log.app.management.WeeklyReportCsv import dev.gf2log.app.management.WeeklyReportStateHolder import dev.gf2log.app.management.WeeklyShareProjection import dev.gf2log.app.management.WeeklyMetricPresentation +import dev.gf2log.app.management.WeeklyMemberNameProjection import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences @@ -212,7 +214,10 @@ class WeeklyReportActivity : LocalizedActivity() { report = report, notes = revision.notes, events = revision.membershipEvents, - namesByUid = revision.memberNamesByUid + report.members.associate { it.uid to it.name }, + namesByUid = WeeklyMemberNameProjection.merge( + reportNamesByUid = report.members.associate { it.uid to it.name }, + capturedNamesByUid = revision.memberNamesByUid, + ), cutlines = WeeklyCutlinePreferences(this).read(), memberNotesByUid = revision.memberPrivateNotesByUid, displayedMembers = MemberOrderPreferences(this).apply(report.members) { it.uid }, @@ -328,6 +333,13 @@ class WeeklyReportActivity : LocalizedActivity() { } }, LinearLayout.LayoutParams(dp(48), dp(48))) }, matchWidth()) + body.addView( + PlatoonProfileSelector.button(this), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { bottomMargin = dp(6) }, + ) body.addView(TextView(this).apply { text = getString( R.string.week_period, @@ -1665,17 +1677,35 @@ class WeeklyReportActivity : LocalizedActivity() { val text = note.text.toString().trim() if (text.isBlank()) return@setOnClickListener val gameDay = report.days[day.selectedItemPosition] - repository.addWeeklyNote( - report.periodStart.toEpochDay(), - gameDay.toEpochDay(), - text, + runCatching { + repository.addWeeklyNote( + report.periodStart.toEpochDay(), + gameDay.toEpochDay(), + text, + ) + }.fold( + onSuccess = { + Toast.makeText( + this@WeeklyReportActivity, + getString(R.string.saved), + Toast.LENGTH_SHORT, + ).show() + requestRender() + }, + onFailure = { error -> + Toast.makeText( + this@WeeklyReportActivity, + getString( + if (error is WeeklyNoteLimitException) { + R.string.weekly_note_limit_reached + } else { + R.string.save_failed + }, + ), + Toast.LENGTH_SHORT, + ).show() + }, ) - Toast.makeText( - this@WeeklyReportActivity, - getString(R.string.saved), - Toast.LENGTH_SHORT, - ).show() - requestRender() } }, matchWidth()) } diff --git a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt new file mode 100644 index 0000000..12f1ab3 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -0,0 +1,42 @@ +package dev.gf2log.app.capture + +/** Bounded pre-identity quarantine; overflow permanently rejects that flow until closure. */ +internal class BoundedFlowPayloadBuffer(private val maxItemsPerFlow: Int) { + private val pending = mutableMapOf>() + private val rejected = mutableSetOf() + + init { + require(maxItemsPerFlow > 0) + } + + fun offer(flowId: Long, item: T): OfferResult { + if (flowId in rejected) return OfferResult.REJECTED + val items = pending.getOrPut(flowId, ::ArrayDeque) + if (items.size >= maxItemsPerFlow) { + pending.remove(flowId) + rejected += flowId + return OfferResult.OVERFLOW + } + items.addLast(item) + return OfferResult.ACCEPTED + } + + fun take(flowId: Long): List = pending.remove(flowId)?.toList().orEmpty() + + fun reject(flowId: Long) { + pending.remove(flowId) + rejected += flowId + } + + fun remove(flowId: Long) { + pending.remove(flowId) + rejected.remove(flowId) + } + + fun clear() { + pending.clear() + rejected.clear() + } + + enum class OfferResult { ACCEPTED, OVERFLOW, REJECTED } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt index 6f6299f..8dcbe2d 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt @@ -26,18 +26,43 @@ internal object CaptureFlowOwnerResolver { remoteAddress: String, remotePort: Int, ): String? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null - val connectivity = context.getSystemService(ConnectivityManager::class.java) - val uid = runCatching { - connectivity.getConnectionOwnerUid( - protocol, - InetSocketAddress(InetAddress.getByName(localAddress), localPort), - InetSocketAddress(InetAddress.getByName(remoteAddress), remotePort), - ) - }.getOrNull() ?: return null - if (uid < 0) return null - return context.packageManager.getPackagesForUid(uid) - .orEmpty() - .firstOrNull { it in SupportedGamePackages.all } + val visiblePackages = SupportedGamePackages.all.mapNotNull { packageName -> + runCatching { + packageName to context.packageManager.getApplicationInfo(packageName, 0).uid + }.getOrNull() + } + val resolved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val connectivity = context.getSystemService(ConnectivityManager::class.java) + val local = InetSocketAddress(InetAddress.getByName(localAddress), localPort) + val remote = InetSocketAddress(InetAddress.getByName(remoteAddress), remotePort) + val forwardUid = runCatching { + connectivity.getConnectionOwnerUid( + protocol, + local, + remote, + ) + }.getOrNull()?.takeIf { it >= 0 } + val forwardPackage = visiblePackages.firstOrNull { it.second == forwardUid }?.first + val reverseUid = if (forwardPackage == null) runCatching { + // Some vendor network stacks report VPN tuples in the opposite direction. + connectivity.getConnectionOwnerUid(protocol, remote, local) + }.getOrNull()?.takeIf { it >= 0 } else null + forwardPackage ?: visiblePackages.firstOrNull { it.second == reverseUid }?.first + } else { + null + } + val installed = visiblePackages.map { it.first } + return CaptureFlowOwnerPolicy.choose(resolved, installed) + } +} + +/** Falls back only when exactly one supported client is installed, avoiding ambiguous attribution. */ +internal object CaptureFlowOwnerPolicy { + fun choose(resolvedPackage: String?, installedPackages: Collection): String? { + if (resolvedPackage in SupportedGamePackages.all) return resolvedPackage + return installedPackages + .filter { it in SupportedGamePackages.all } + .distinct() + .singleOrNull() } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt new file mode 100644 index 0000000..3e18555 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt @@ -0,0 +1,13 @@ +package dev.gf2log.app.capture + +/** Removes every piece of per-flow state even when no stream parser was ever created. */ +internal object CaptureFlowStateCleanup { + fun remove( + flowId: Long, + parsers: MutableMap, + metadata: MutableMap, + ): T? { + metadata.remove(flowId) + return parsers.remove(flowId) + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt index 006d47a..c9feec2 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -15,13 +15,17 @@ import android.os.ParcelFileDescriptor import dev.gf2log.app.R import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.settings.CapturePreferences +import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.protocol.Gfl2StreamParser import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.PayloadCatalog import dev.gf2log.protocol.model.ParseEvent +import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData import java.io.File import java.time.Instant @@ -38,6 +42,10 @@ class CaptureVpnService : VpnService() { private val parsers = ConcurrentHashMap() private val taintedFlows = ConcurrentHashMap.newKeySet() private val flowMetadata = ConcurrentHashMap() + private val flowSessions = ConcurrentHashMap() + private val pendingFlowPayloads = BoundedFlowPayloadBuffer( + MAX_PENDING_PAYLOADS_PER_FLOW, + ) private val decodedPayloadCount = AtomicLong() private val observedPayloadBytes = AtomicLong() private val inspectedPayloadBytes = AtomicLong() @@ -45,24 +53,24 @@ class CaptureVpnService : VpnService() { private val parseWarningCount = AtomicLong() private val droppedParserTaskCount = AtomicLong() private val unknownPayloadCounts = ConcurrentHashMap() - private val capturedRequiredTypes = ConcurrentHashMap.newKeySet() + private val captureChecklist = ScopedCaptureChecklist(REQUIRED_CAPTURE_TYPES) private val mainHandler = Handler(Looper.getMainLooper()) - private lateinit var guildMembersWriter: GuildMembersCsvWriter private lateinit var historyStore: CaptureHistoryStore - private lateinit var platoonRepository: PlatoonRepository + private lateinit var profileRegistry: PlatoonProfileRegistry + private lateinit var clientServerRegions: ClientServerRegionPreferences private lateinit var payloadHistoryPreferences: PayloadHistoryPreferences private lateinit var capturePreferences: CapturePreferences private lateinit var diagnosticsStore: CaptureDiagnosticsStore - private lateinit var platoonPayloadDispatcher: PlatoonPayloadDispatcher private var sessionStartedAt: Instant? = null private var captureOnce = false private val captureOnceGraceStop = Runnable { + val captured = captureChecklist.targetCaptured() if (captureOnce && CaptureStatus.isRunning && - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS in capturedRequiredTypes + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS in captured ) { val missing = REQUIRED_CAPTURE_TYPES - .minus(capturedRequiredTypes) + .minus(captured) .map(PayloadCatalog::tag) .joinToString() stopCapture("Captured Platoon roster; missing $missing after the navigation period") @@ -77,56 +85,14 @@ class CaptureVpnService : VpnService() { override fun onCreate() { super.onCreate() - platoonRepository = PlatoonRepository(this) - guildMembersWriter = GuildMembersCsvWriter( - File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY), - ) { batch -> - val directResult = runCatching { - platoonRepository.ingest( - capturedAt = Instant.parse(batch.logTime), - members = batch.members, - sourceFile = batch.file.name, - ) - } - if (directResult.isSuccess) { - val result = directResult.getOrThrow() - if (!result.duplicate) { - CaptureStatus.update( - "Updated Platoon roster: +${result.joined + result.rejoined}, " + - "-${result.left}", - ) - } - markRosterCaptured() - } else { - val recovery = runCatching { - require(batch.file.isFile) { "Completed roster CSV was not published" } - platoonRepository.reconcileRetainedCsvFiles( - requireNotNull(batch.file.parentFile), - ) - check(platoonRepository.hasSnapshotSource(batch.file.name)) { - "Completed roster CSV was not reconciled" - } - } - if (recovery.isFailure) { - throw directResult.exceptionOrNull() - ?: recovery.exceptionOrNull() - ?: IllegalStateException("Roster ingestion and recovery both failed") - } - CaptureStatus.update("Recovered Platoon database from the completed roster CSV") - markRosterCaptured() - } - } + profileRegistry = PlatoonProfileRegistry(this) + clientServerRegions = ClientServerRegionPreferences(this) historyStore = CaptureHistoryStore( File(filesDir, CaptureHistoryStore.HISTORY_DIRECTORY), ) payloadHistoryPreferences = PayloadHistoryPreferences(this) capturePreferences = CapturePreferences(this) diagnosticsStore = CaptureDiagnosticsStore(this) - platoonPayloadDispatcher = PlatoonPayloadDispatcher( - onMembers = guildMembersWriter::accept, - onActivity = { platoonRepository.ingestActivity(it).acceptedObservations > 0 }, - onUpdates = { platoonRepository.ingestUpdates(it).acceptedObservations > 0 }, - ) createNotificationChannel() } @@ -153,7 +119,7 @@ class CaptureVpnService : VpnService() { if (CaptureStatus.isRunning) CaptureStatus.markStopped() mainHandler.removeCallbacksAndMessages(null) migrationExecutor.shutdownNow() - guildMembersWriter.close() + closeAllFlowSessions() super.onDestroy() } @@ -178,9 +144,10 @@ class CaptureVpnService : VpnService() { captureStartPending = true migrationExecutor.execute { val migration = runCatching { - platoonRepository.reconcileRetainedCsvFiles( - File(filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY), - ) + profileRegistry.ensureInitialized().forEach { profile -> + val scope = PlatoonStorageScope(profile.storageId) + PlatoonRepository(this, scope).reconcileRetainedCsvFiles() + } } mainHandler.post { if (!captureStartPending) return@post @@ -234,6 +201,8 @@ class CaptureVpnService : VpnService() { parsers.clear() taintedFlows.clear() flowMetadata.clear() + closeAllFlowSessions() + pendingFlowPayloads.clear() decodedPayloadCount.set(0) observedPayloadBytes.set(0) inspectedPayloadBytes.set(0) @@ -241,7 +210,7 @@ class CaptureVpnService : VpnService() { parseWarningCount.set(0) droppedParserTaskCount.set(0) unknownPayloadCounts.clear() - capturedRequiredTypes.clear() + captureChecklist.clear() mainHandler.removeCallbacks(captureOnceGraceStop) sessionStartedAt = Instant.now() @@ -347,17 +316,28 @@ class CaptureVpnService : VpnService() { private fun enqueueFlowClosed(flowId: Long) { if (!submitParserTask { + val metadata = flowMetadata[flowId] if (taintedFlows.remove(flowId)) { - parsers.remove(flowId) - flowMetadata.remove(flowId) + CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) + closeFlowSession(flowId) return@submitParserTask } - val parser = parsers.remove(flowId) ?: return@submitParserTask - processEvents(flowId, parser.finish(), flowEnded = true) - flowMetadata.remove(flowId) + val parser = CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) + if (parser != null) { + processEvents( + flowId = flowId, + events = parser.finish(), + metadata = metadata, + flowEnded = true, + ) + } + closeFlowSession(flowId) } ) { - parsers.remove(flowId) + CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) + closeFlowSession(flowId) + // Earlier queued chunks may still run even though the close task was rejected. + // Keep the flow tainted until the capture session resets all parser state. taintedFlows += flowId } } @@ -378,6 +358,7 @@ class CaptureVpnService : VpnService() { private fun processEvents( flowId: Long, events: List, + metadata: CaptureFlowMetadata? = flowMetadata[flowId], flowEnded: Boolean = false, ) { val warnings = events.filterIsInstance() @@ -387,73 +368,161 @@ class CaptureVpnService : VpnService() { events.filterIsInstance().forEach { event -> unknownPayloadCounts.computeIfAbsent(event.payloadType) { AtomicLong() }.incrementAndGet() } - decoded.forEach { event -> + decoded.forEachIndexed { index, event -> if (event.value.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { val profile = event.value.data as? PlatoonProfileData - if (profile != null && profile.platoonId != 0u && profile.platoonName.isNotBlank()) { - val client = when (flowMetadata[flowId]?.ownerPackage) { - SupportedGamePackages.HAOPLAY -> "HaoPlay" - SupportedGamePackages.DARKWINTER -> "Darkwinter" - else -> "unknown client" - } - val name = profile.platoonName.replace(Regex("\\s+"), " ").take(40) - CaptureStatus.update("Detected $name (${profile.platoonId}) via $client") + if (PlatoonProfilePolicy.isValid(profile)) { + requireNotNull(profile) + identifyFlow(flowId, metadata, profile) } - markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) } if (payloadHistoryPreferences.isEnabled(event.value.payloadType)) { runCatching { historyStore.save(event.value) } .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } } - val routed = platoonPayloadDispatcher.dispatch(event.value, flowEnded) - routed.activity - ?.onSuccess { - markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) - } - ?.onFailure { CaptureStatus.update("Unable to update Platoon activity history") } - routed.updates - ?.onSuccess { - markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) - } - ?.onFailure { CaptureStatus.update("Unable to update exact Platoon history") } - routed.members - .onSuccess { saved -> - if (saved != null) { - CaptureStatus.update( - "Saved ${saved.rowCount} Platoon members to ${saved.file.name}", - ) - } - } - .onFailure { CaptureStatus.update("Unable to save Platoon CSV") } + val session = flowSessions[flowId] + if (session == null) { + retainPendingPayload(flowId, event.value) + return@forEachIndexed + } + routePayload( + session, + event.value, + flowEnded = flowEnded && index == decoded.lastIndex, + ) } if (decoded.isNotEmpty()) decodedPayloadCount.addAndGet(decoded.size.toLong()) } + private fun identifyFlow( + flowId: Long, + metadata: CaptureFlowMetadata?, + data: PlatoonProfileData, + ) { + val ownerPackage = metadata?.ownerPackage + if (ownerPackage !in SupportedGamePackages.all) { + pendingFlowPayloads.reject(flowId) + CaptureStatus.update("Detected a Platoon profile, but its game client could not be verified") + return + } + val profile = runCatching { + profileRegistry.upsertDetected( + ownerPackage = requireNotNull(ownerPackage), + region = clientServerRegions.get(ownerPackage), + data = data, + ) + }.getOrElse { + pendingFlowPayloads.reject(flowId) + CaptureStatus.update("Unable to isolate the detected Platoon") + return + } + val current = flowSessions[flowId] + if (current?.profile?.storageId == profile.storageId) { + markRequiredPayloadCaptured( + profile.storageId, + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + ) + return + } + current?.close() + val session = PlatoonCaptureSession( + context = this, + profile = profile, + onRosterCaptured = ::markRosterCaptured, + onStatus = CaptureStatus::update, + ) + flowSessions[flowId] = session + pendingFlowPayloads.take(flowId).forEach { pending -> + routePayload(session, pending) + } + CaptureStatus.update( + "Detected ${profile.platoonName.take(40)} (${profile.platoonId}) via " + + profile.client.displayName, + ) + markRequiredPayloadCaptured( + profile.storageId, + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + ) + } + + private fun retainPendingPayload(flowId: Long, payload: ParsedPayload) { + when (pendingFlowPayloads.offer(flowId, payload)) { + BoundedFlowPayloadBuffer.OfferResult.OVERFLOW -> + CaptureStatus.update("Discarded an unidentified Platoon flow that exceeded its buffer") + BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, + BoundedFlowPayloadBuffer.OfferResult.REJECTED, + -> Unit + } + } + + private fun routePayload( + session: PlatoonCaptureSession, + payload: ParsedPayload, + flowEnded: Boolean = false, + ) { + val routed = session.dispatch(payload, flowEnded) + routed.activity?.onSuccess { accepted -> + if (accepted) { + markRequiredPayloadCaptured( + session.profile.storageId, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, + ) + } + }?.onFailure { CaptureStatus.update("Unable to update Platoon activity history") } + routed.updates?.onSuccess { accepted -> + if (accepted) { + markRequiredPayloadCaptured( + session.profile.storageId, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + ) + } + }?.onFailure { CaptureStatus.update("Unable to update exact Platoon history") } + routed.members.onSuccess { saved -> + if (saved != null) { + CaptureStatus.update( + "Saved ${saved.rowCount} members for ${session.profile.platoonName}", + ) + } + }.onFailure { CaptureStatus.update("Unable to save Platoon CSV") } + } + + private fun closeFlowSession(flowId: Long) { + pendingFlowPayloads.remove(flowId) + flowSessions.remove(flowId)?.close() + } + + private fun closeAllFlowSessions() { + flowSessions.values.forEach { runCatching { it.close() } } + flowSessions.clear() + } + private fun maybeStopCaptureOnce() { if (!captureOnce || !CaptureStatus.isRunning) return - if (!capturedRequiredTypes.containsAll(REQUIRED_CAPTURE_TYPES)) return + if (!captureChecklist.targetComplete()) return mainHandler.removeCallbacks(captureOnceGraceStop) mainHandler.post { if (captureOnce && CaptureStatus.isRunning && - capturedRequiredTypes.containsAll(REQUIRED_CAPTURE_TYPES) + captureChecklist.targetComplete() ) { stopCapture("Captured Platoon roster, activity, and updates") } } } - private fun markRosterCaptured() { - markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) - if (captureOnce) { + private fun markRosterCaptured(storageId: String) { + markRequiredPayloadCaptured(storageId, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) + if (captureOnce && captureChecklist.targetScopeId() == storageId) { mainHandler.removeCallbacks(captureOnceGraceStop) mainHandler.postDelayed(captureOnceGraceStop, CAPTURE_ONCE_GRACE_MILLIS) } } - private fun markRequiredPayloadCaptured(payloadType: Int) { - capturedRequiredTypes += payloadType - CaptureStatus.markUsefulPayload(payloadType) + private fun markRequiredPayloadCaptured(storageId: String, payloadType: Int) { + captureChecklist.mark(storageId, payloadType, chooseTarget = captureOnce) + if (!captureOnce || captureChecklist.targetScopeId() == storageId) { + CaptureStatus.markUsefulPayload(payloadType) + } maybeStopCaptureOnce() } @@ -479,6 +548,8 @@ class CaptureVpnService : VpnService() { drainParserTasks() parsers.clear() flowMetadata.clear() + closeAllFlowSessions() + pendingFlowPayloads.clear() CaptureStatus.markStopped("Capture stopped unexpectedly; press Prepare capture to retry") stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -509,6 +580,8 @@ class CaptureVpnService : VpnService() { parsers.clear() taintedFlows.clear() flowMetadata.clear() + closeAllFlowSessions() + pendingFlowPayloads.clear() saveDiagnostics() } @@ -616,6 +689,7 @@ class CaptureVpnService : VpnService() { private const val PARSER_DRAIN_TIMEOUT_SECONDS = 3L private const val TRAFFIC_REPORT_BYTES = 64 * 1024 private const val CAPTURE_ONCE_GRACE_MILLIS = 60_000L + private const val MAX_PENDING_PAYLOADS_PER_FLOW = 32 private val REQUIRED_CAPTURE_TYPES = setOf( Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt new file mode 100644 index 0000000..df72d3c --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt @@ -0,0 +1,69 @@ +package dev.gf2log.app.capture + +import android.content.Context +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.protocol.model.ParsedPayload +import java.time.Instant + +/** Owns all mutable capture-to-management state for one identified TCP flow. */ +internal class PlatoonCaptureSession( + context: Context, + val profile: PlatoonProfile, + private val onRosterCaptured: (String) -> Unit, + private val onStatus: (String) -> Unit, +) : AutoCloseable { + private val scope = PlatoonStorageScope(profile.storageId) + private val repository = PlatoonRepository(context, scope) + private val writer = GuildMembersCsvWriter( + scope.retainedCsvDirectory(context), + onBatchClosed = { batch -> ingestCompletedRoster(batch) }, + ) + private val dispatcher = PlatoonPayloadDispatcher( + onMembers = writer::accept, + onActivity = { repository.ingestActivity(it).acceptedObservations > 0 }, + onUpdates = { repository.ingestUpdates(it).acceptedObservations > 0 }, + ) + + fun dispatch(payload: ParsedPayload, flowEnded: Boolean = false): PlatoonPayloadDispatcher.Results = + dispatcher.dispatch(payload, flowEnded) + + override fun close() = writer.close() + + private fun ingestCompletedRoster(batch: GuildMembersCsvWriter.CompletedBatch) { + val directResult = runCatching { + repository.ingest( + capturedAt = Instant.parse(batch.logTime), + members = batch.members, + sourceFile = batch.file.name, + ) + } + if (directResult.isSuccess) { + val result = directResult.getOrThrow() + if (!result.duplicate) { + onStatus( + "Updated ${profile.platoonName}: +${result.joined + result.rejoined}, " + + "-${result.left}", + ) + } + onRosterCaptured(profile.storageId) + return + } + + val recovery = runCatching { + require(batch.file.isFile) { "Completed roster CSV was not published" } + repository.reconcileRetainedCsvFiles(requireNotNull(batch.file.parentFile)) + check(repository.hasSnapshotSource(batch.file.name)) { + "Completed roster CSV was not reconciled" + } + } + if (recovery.isFailure) { + throw directResult.exceptionOrNull() + ?: recovery.exceptionOrNull() + ?: IllegalStateException("Roster ingestion and recovery both failed") + } + onStatus("Recovered ${profile.platoonName} from the completed roster CSV") + onRosterCaptured(profile.storageId) + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt new file mode 100644 index 0000000..a6bdb97 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt @@ -0,0 +1,14 @@ +package dev.gf2log.app.capture + +import dev.gf2log.protocol.model.PlatoonProfileData + +/** Validates that payload 21905 contains an identity that is safe to use for capture routing. */ +internal object PlatoonProfilePolicy { + private const val MAX_NAME_LENGTH = 128 + + fun isValid(profile: PlatoonProfileData?): Boolean = profile != null && + profile.platoonId != 0u && + profile.platoonName.isNotBlank() && + profile.platoonName.length <= MAX_NAME_LENGTH && + profile.platoonName.none(Char::isISOControl) +} diff --git a/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt b/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt new file mode 100644 index 0000000..d3b6331 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt @@ -0,0 +1,34 @@ +package dev.gf2log.app.capture + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +/** Keeps guided-capture evidence isolated so payloads from two clients cannot complete one run. */ +internal class ScopedCaptureChecklist(private val requiredTypes: Set) { + private val capturedByScope = ConcurrentHashMap>() + private val targetScope = AtomicReference(null) + + fun mark(scopeId: String, payloadType: Int, chooseTarget: Boolean): Set { + require(scopeId.isNotBlank()) + if (chooseTarget) targetScope.compareAndSet(null, scopeId) + val captured = capturedByScope.computeIfAbsent(scopeId) { + ConcurrentHashMap.newKeySet() + } + captured += payloadType + return captured.toSet() + } + + fun targetScopeId(): String? = targetScope.get() + + fun targetCaptured(): Set = targetScope.get() + ?.let(capturedByScope::get) + ?.toSet() + .orEmpty() + + fun targetComplete(): Boolean = targetCaptured().containsAll(requiredTypes) + + fun clear() { + capturedByScope.clear() + targetScope.set(null) + } +} 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 7fc219c..a48f74e 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -16,13 +16,20 @@ import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream internal object BackupArchive { - fun write(output: OutputStream, database: File, settings: ByteArray?) { + fun write( + output: OutputStream, + database: File, + settings: ByteArray?, + profile: PlatoonProfile? = null, + ) { require(database.isFile) { "No Platoon database exists" } require(database.length() <= MAX_DATABASE_BYTES) { "Platoon database is too large" } require(settings == null || settings.size <= MAX_SETTINGS_BYTES) { "Backup settings are too large" } - val formatVersion = if (settings == null) { + val formatVersion = if (profile != null) { + BackupFormatPolicy.SCOPED_VERSION + } else if (settings == null) { BackupFormatPolicy.PLATOON_ONLY_VERSION } else { BackupFormatPolicy.COMPLETE_VERSION @@ -32,7 +39,15 @@ internal object BackupArchive { Properties().apply { setProperty(KEY_FORMAT_VERSION, formatVersion.toString()) setProperty(KEY_DATABASE_SHA256, database.sha256()) - if (settings != null) { + if (profile != null) { + setProperty(KEY_APPLICATION_ID, APPLICATION_ID) + setProperty( + KEY_BACKUP_SCOPE, + if (settings == null) PLATOON_SCOPE else COMPLETE_SCOPE, + ) + putProfile(profile) + if (settings != null) setProperty(KEY_SETTINGS_SHA256, settings.sha256()) + } else if (settings != null) { setProperty(KEY_APPLICATION_ID, APPLICATION_ID) setProperty(KEY_BACKUP_SCOPE, COMPLETE_SCOPE) setProperty(KEY_SETTINGS_SHA256, settings.sha256()) @@ -101,14 +116,19 @@ internal object BackupArchive { formatVersion in setOf( BackupFormatPolicy.PLATOON_ONLY_VERSION, BackupFormatPolicy.COMPLETE_VERSION, + BackupFormatPolicy.SCOPED_VERSION, ), ) { "Unsupported backup version" } - val expectedManifestKeys = if ( - formatVersion == BackupFormatPolicy.PLATOON_ONLY_VERSION - ) { - LEGACY_MANIFEST_KEYS - } else { - COMPLETE_MANIFEST_KEYS + val expectedManifestKeys = when (formatVersion) { + BackupFormatPolicy.PLATOON_ONLY_VERSION -> LEGACY_MANIFEST_KEYS + BackupFormatPolicy.COMPLETE_VERSION -> COMPLETE_MANIFEST_KEYS + else -> SCOPED_MANIFEST_KEYS + if ( + metadata.getProperty(KEY_BACKUP_SCOPE) == COMPLETE_SCOPE + ) { + setOf(KEY_SETTINGS_SHA256) + } else { + emptySet() + } } require(metadata.stringPropertyNames() == expectedManifestKeys) { "Backup manifest is incomplete or contains unknown fields" @@ -118,7 +138,22 @@ internal object BackupArchive { .equals(stagedDatabase.sha256(), ignoreCase = true), ) { "Backup database checksum does not match" } - if (formatVersion == BackupFormatPolicy.COMPLETE_VERSION) { + val profile = if (formatVersion == BackupFormatPolicy.SCOPED_VERSION) { + require(metadata.getProperty(KEY_APPLICATION_ID) == APPLICATION_ID) { + "Backup belongs to another application" + } + require(metadata.getProperty(KEY_BACKUP_SCOPE) in setOf(PLATOON_SCOPE, COMPLETE_SCOPE)) { + "Backup scope is invalid" + } + metadata.profile() + } else { + null + } + + if ( + formatVersion == BackupFormatPolicy.COMPLETE_VERSION || + metadata.getProperty(KEY_BACKUP_SCOPE) == COMPLETE_SCOPE + ) { require(metadata.getProperty(KEY_APPLICATION_ID) == APPLICATION_ID) { "Backup belongs to another application" } @@ -133,7 +168,7 @@ internal object BackupArchive { } else { require(settingsBytes == null) { "Legacy backup contains unexpected settings" } } - return StagedArchive(formatVersion, settingsBytes) + return StagedArchive(formatVersion, settingsBytes, profile) } catch (error: Exception) { stagedDatabase.delete() throw error @@ -143,8 +178,84 @@ internal object BackupArchive { data class StagedArchive( val formatVersion: Int, val settings: ByteArray?, + val profile: BackupPlatoonProfile? = null, ) + internal data class BackupPlatoonProfile( + val storageId: String, + val client: PlatoonClient, + val serverRegion: dev.gf2log.app.settings.GameServerRegion, + val platoonId: Long, + val platoonName: String, + val emblemPrimary: List, + val emblemSecondary: List, + val legacy: Boolean, + ) { + fun toProfile() = PlatoonProfile( + storageId = storageId, + client = client, + serverRegion = serverRegion, + platoonId = platoonId, + platoonName = platoonName, + emblemPrimary = emblemPrimary, + emblemSecondary = emblemSecondary, + lastSeenAt = java.time.Instant.now(), + legacy = legacy, + ) + } + + private fun Properties.putProfile(profile: PlatoonProfile) { + setProperty(KEY_PROFILE_STORAGE_ID, profile.storageId) + setProperty(KEY_PROFILE_CLIENT, profile.client.name) + setProperty(KEY_PROFILE_REGION, profile.serverRegion.storedValue) + setProperty(KEY_PROFILE_PLATOON_ID, profile.platoonId.toString()) + setProperty(KEY_PROFILE_NAME, profile.platoonName) + setProperty(KEY_PROFILE_EMBLEM_PRIMARY, profile.emblemPrimary.joinToString(",")) + setProperty(KEY_PROFILE_EMBLEM_SECONDARY, profile.emblemSecondary.joinToString(",")) + setProperty(KEY_PROFILE_LEGACY, profile.legacy.toString()) + } + + private fun Properties.profile(): BackupPlatoonProfile { + val client = PlatoonClient.valueOf(required(KEY_PROFILE_CLIENT)) + val region = dev.gf2log.app.settings.GameServerRegion.fromStored(required(KEY_PROFILE_REGION)) + require(region.storedValue == required(KEY_PROFILE_REGION)) { "Backup server region is invalid" } + val id = requireNotNull(required(KEY_PROFILE_PLATOON_ID).toLongOrNull()) { + "Backup Platoon ID is invalid" + } + val legacy = required(KEY_PROFILE_LEGACY).let { value -> + require(value in setOf("true", "false")) { "Backup legacy marker is invalid" } + value.toBooleanStrict() + } + val result = BackupPlatoonProfile( + storageId = required(KEY_PROFILE_STORAGE_ID), + client = client, + serverRegion = region, + platoonId = id, + platoonName = required(KEY_PROFILE_NAME), + emblemPrimary = longList(required(KEY_PROFILE_EMBLEM_PRIMARY)), + emblemSecondary = longList(required(KEY_PROFILE_EMBLEM_SECONDARY)), + legacy = legacy, + ) + result.toProfile() + if (!legacy) { + require( + result.storageId == PlatoonProfileIdentity.storageId(client, region, id), + ) { "Backup Platoon identity does not match its storage scope" } + } + return result + } + + private fun Properties.required(key: String): String = + requireNotNull(getProperty(key)) { "Backup manifest is missing $key" } + + private fun longList(value: String): List = if (value.isBlank()) { + emptyList() + } else { + value.split(',').map { item -> + requireNotNull(item.toLongOrNull()) { "Backup emblem data is invalid" } + }.also { require(it.size <= PlatoonProfile.MAX_EMBLEM_PARTS) } + } + private fun InputStream.copyBoundedTo(output: OutputStream, maximum: Long) { val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var total = 0L @@ -189,8 +300,17 @@ internal object BackupArchive { private const val KEY_APPLICATION_ID = "applicationId" private const val KEY_BACKUP_SCOPE = "backupScope" private const val KEY_SETTINGS_SHA256 = "settingsSha256" + private const val KEY_PROFILE_STORAGE_ID = "profile.storageId" + private const val KEY_PROFILE_CLIENT = "profile.client" + private const val KEY_PROFILE_REGION = "profile.region" + private const val KEY_PROFILE_PLATOON_ID = "profile.platoonId" + private const val KEY_PROFILE_NAME = "profile.name" + private const val KEY_PROFILE_EMBLEM_PRIMARY = "profile.emblemPrimary" + private const val KEY_PROFILE_EMBLEM_SECONDARY = "profile.emblemSecondary" + private const val KEY_PROFILE_LEGACY = "profile.legacy" private const val APPLICATION_ID = "dev.gf2log" private const val COMPLETE_SCOPE = "complete" + private const val PLATOON_SCOPE = "platoon" private const val MAX_MANIFEST_BYTES = 64L * 1024 private const val MAX_SETTINGS_BYTES = 256L * 1024 private const val MAX_DATABASE_BYTES = 50L * 1024 * 1024 @@ -201,4 +321,16 @@ internal object BackupArchive { KEY_BACKUP_SCOPE, KEY_SETTINGS_SHA256, ) + private val SCOPED_MANIFEST_KEYS = LEGACY_MANIFEST_KEYS + setOf( + KEY_APPLICATION_ID, + KEY_BACKUP_SCOPE, + KEY_PROFILE_STORAGE_ID, + KEY_PROFILE_CLIENT, + KEY_PROFILE_REGION, + KEY_PROFILE_PLATOON_ID, + KEY_PROFILE_NAME, + KEY_PROFILE_EMBLEM_PRIMARY, + KEY_PROFILE_EMBLEM_SECONDARY, + KEY_PROFILE_LEGACY, + ) } diff --git a/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt b/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt index 2d15649..df2105c 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt @@ -3,15 +3,18 @@ package dev.gf2log.app.management internal object BackupFormatPolicy { const val PLATOON_ONLY_VERSION = 1 const val COMPLETE_VERSION = 2 + const val SCOPED_VERSION = 3 fun requirePlatoonOnly(formatVersion: Int, hasSettings: Boolean) { - require(formatVersion == PLATOON_ONLY_VERSION && !hasSettings) { + require( + formatVersion in setOf(PLATOON_ONLY_VERSION, SCOPED_VERSION) && !hasSettings, + ) { "Complete backups must be restored from Settings" } } fun requireComplete(formatVersion: Int, hasSettings: Boolean) { - require(formatVersion == COMPLETE_VERSION && hasSettings) { + require(formatVersion in setOf(COMPLETE_VERSION, SCOPED_VERSION) && hasSettings) { "Backup does not contain complete app settings" } } diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index 90bd248..6d94684 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -14,14 +14,27 @@ import java.io.FileOutputStream */ class CsvImportCheckpointManager internal constructor( context: Context, + private val storageScope: PlatoonStorageScope = + PlatoonProfileRegistry(context).activeScope(), private val restoreObserver: (PlatoonBackupManager.RestoreCheckpoint) -> Unit, ) { - constructor(context: Context) : this(context, {}) + constructor(context: Context) : this( + context, + PlatoonProfileRegistry(context).activeScope(), + {}, + ) + + internal constructor(context: Context, storageScope: PlatoonStorageScope) : this( + context, + storageScope, + {}, + ) 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) + private val profileRoot = storageScope.rootDirectory(appContext) + private val root = File(profileRoot, CHECKPOINT_DIRECTORY) + private val staging = File(profileRoot, STAGING_DIRECTORY) + private val previous = File(profileRoot, PREVIOUS_DIRECTORY) init { synchronized(STATE_LOCK) { @@ -55,7 +68,11 @@ class CsvImportCheckpointManager internal constructor( deleteDirectory(staging) check(staging.mkdirs()) { "Unable to stage the CSV import checkpoint" } FileOutputStream(archive(staging)).use { output -> - PlatoonBackupManager(appContext).export(output) + PlatoonBackupManager( + appContext, + AppSettingsStore(appContext), + storageScope = storageScope, + ).export(output) output.fd.sync() } FileOutputStream(manifest(staging)).use { output -> @@ -91,7 +108,11 @@ class CsvImportCheckpointManager internal constructor( try { FileOutputStream(temporary).use { output -> output.write( - PlatoonBackupManager(appContext).currentDatabaseSha256() + PlatoonBackupManager( + appContext, + AppSettingsStore(appContext), + storageScope = storageScope, + ).currentDatabaseSha256() .toByteArray(Charsets.US_ASCII), ) output.fd.sync() @@ -120,7 +141,13 @@ class CsvImportCheckpointManager internal constructor( } val expectedDigest = digest(root).readText(Charsets.US_ASCII) require(expectedDigest.matches(SHA256)) { "Invalid CSV checkpoint digest" } - check(PlatoonBackupManager(appContext).currentDatabaseSha256() == expectedDigest) { + check( + PlatoonBackupManager( + appContext, + AppSettingsStore(appContext), + storageScope = storageScope, + ).currentDatabaseSha256() == expectedDigest, + ) { "Platoon data changed after the CSV import; undo would overwrite newer changes" } restoreUnchecked() @@ -144,7 +171,7 @@ class CsvImportCheckpointManager internal constructor( .toSet() require(names.size <= MAX_PLANNED_FILES) names.forEach(::requireSafeImportName) - val retained = File(appContext.filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + val retained = storageScope.retainedCsvDirectory(appContext) val quarantine = File(root, QUARANTINE_DIRECTORY) if (!quarantine.exists()) { check(quarantine.mkdirs()) { "Unable to create the retained CSV quarantine" } @@ -164,6 +191,7 @@ class CsvImportCheckpointManager internal constructor( val backupManager = PlatoonBackupManager( context = appContext, settingsStore = AppSettingsStore(appContext), + storageScope = storageScope, restoreObserver = { checkpoint -> if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { markRestoreDatabaseInstalled() diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt index ba0b204..1183714 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt @@ -59,7 +59,13 @@ internal object MembershipConsistencyPolicy { } else { requireNotNull(first.joinedAt).compareTo(requireNotNull(second.joinedAt)) } - return if (dateOrder != 0) dateOrder else first.id.compareTo(second.id) + if (dateOrder != 0) return dateOrder + if (first.joinedTimeKnown && second.joinedTimeKnown) { + val instantOrder = requireNotNull(first.joinedAt) + .compareTo(requireNotNull(second.joinedAt)) + if (instantOrder != 0) return instantOrder + } + return first.id.compareTo(second.id) } private fun periodsProvablyOverlap( 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 17f3702..19f3b0e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -6,8 +6,10 @@ import android.database.sqlite.SQLiteException import android.util.AtomicFile import dev.gf2log.app.settings.AppBackupSettings import dev.gf2log.app.settings.AppBackupSettingsCodec -import dev.gf2log.app.settings.AppSettingsStore import dev.gf2log.app.settings.BackupSettingsStore +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.ScopedAppSettingsStore import java.io.EOFException import java.io.File import java.io.InputStream @@ -19,26 +21,47 @@ class PlatoonBackupManager internal constructor( context: Context, private val settingsStore: BackupSettingsStore, private val restoreObserver: (RestoreCheckpoint) -> Unit = {}, + private val storageScope: PlatoonStorageScope = + PlatoonProfileRegistry(context).activeScope(), ) { - constructor(context: Context) : this(context, AppSettingsStore(context.applicationContext)) + private constructor(context: Context, storageScope: PlatoonStorageScope) : this( + context = context, + settingsStore = ScopedAppSettingsStore( + context.applicationContext, + storageScope.storageId, + ), + storageScope = storageScope, + ) + + constructor(context: Context) : this(context, PlatoonProfileRegistry(context).activeScope()) private val appContext = context.applicationContext private val databaseFile: File - get() = appContext.getDatabasePath(PlatoonSchema.DATABASE_NAME) + get() = appContext.getDatabasePath(storageScope.databaseName) + + private val restoreDirectory: File + get() = File(appContext.cacheDir, "platoon-restore/${storageScope.storageId}") init { - recoverInterruptedFullRestore(appContext, settingsStore) + recoverInterruptedFullRestore(appContext, settingsStore, storageScope) } fun export(output: OutputStream) { - PlatoonRepository(appContext).reconcileRetainedCsvFiles() - PlatoonRepository.withExclusiveDatabase { - BackupArchive.write(output, databaseFile, settings = null) + PlatoonRepository(appContext, storageScope).reconcileRetainedCsvFiles() + PlatoonRepository.withExclusiveDatabase(storageScope) { + ensureDatabaseExists() + BackupArchive.write( + output, + databaseFile, + settings = null, + profile = backupProfile(), + ) } } /** Returns a stable digest after closing SQLite so WAL state is checkpointed. */ - internal fun currentDatabaseSha256(): String = PlatoonRepository.withExclusiveDatabase { + internal fun currentDatabaseSha256(): String = + PlatoonRepository.withExclusiveDatabase(storageScope) { ensureDatabaseExists() val digest = MessageDigest.getInstance("SHA-256") databaseFile.inputStream().use { input -> @@ -55,16 +78,21 @@ class PlatoonBackupManager internal constructor( } fun exportFull(output: OutputStream) { - PlatoonRepository(appContext).reconcileRetainedCsvFiles() - PlatoonRepository.withExclusiveDatabase { + PlatoonRepository(appContext, storageScope).reconcileRetainedCsvFiles() + PlatoonRepository.withExclusiveDatabase(storageScope) { val settings = settingsStore.read() ensureDatabaseExists() - BackupArchive.write(output, databaseFile, AppBackupSettingsCodec.encode(settings)) + BackupArchive.write( + output, + databaseFile, + AppBackupSettingsCodec.encode(settings), + backupProfile(), + ) } } fun restore(input: InputStream) { - val restoreDirectory = File(appContext.cacheDir, "platoon-restore").apply { mkdirs() } + restoreDirectory.mkdirs() val stagedDatabase = File(restoreDirectory, "platoon.db.staged") if (stagedDatabase.exists() && !stagedDatabase.delete()) { error("Unable to clear a previous staged restore") @@ -73,14 +101,17 @@ class PlatoonBackupManager internal constructor( BackupArchive.stage(input, stagedDatabase) } try { - validateSelectedBackup { - BackupFormatPolicy.requirePlatoonOnly( - staged.formatVersion, - staged.settings != null, - ) - validateDatabase(stagedDatabase, requireCurrentSchema = false) + val target = managerFor(staged) + try { + target.manager.restoreStagedPlatoon(stagedDatabase, staged) + target.installProfileMetadata() + activateRestoredProfile(target.manager.storageScope) + } catch (error: Exception) { + runCatching(target::rollbackProfileMetadata) + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error } - replaceRestoredState(stagedDatabase, restoredSettings = null) } finally { stagedDatabase.delete() } @@ -95,7 +126,7 @@ class PlatoonBackupManager internal constructor( // Returns: // - Unit after validated, crash-aware database replacement. internal fun restoreCheckpoint(input: InputStream) { - val restoreDirectory = File(appContext.cacheDir, "platoon-restore").apply { mkdirs() } + restoreDirectory.mkdirs() val stagedDatabase = File(restoreDirectory, "platoon.db.staged") if (stagedDatabase.exists() && !stagedDatabase.delete()) { error("Unable to clear a previous staged restore") @@ -109,6 +140,7 @@ class PlatoonBackupManager internal constructor( staged.formatVersion, staged.settings != null, ) + requireArchiveMatchesScope(staged) validateDatabase(stagedDatabase, requireCurrentSchema = true) } replaceRestoredState(stagedDatabase, restoredSettings = null, retireRetainedCsv = false) @@ -118,7 +150,7 @@ class PlatoonBackupManager internal constructor( } fun restoreFull(input: InputStream) { - val restoreDirectory = File(appContext.cacheDir, "platoon-restore").apply { mkdirs() } + restoreDirectory.mkdirs() val stagedDatabase = File(restoreDirectory, "platoon.db.staged") if (stagedDatabase.exists() && !stagedDatabase.delete()) { error("Unable to clear a previous staged restore") @@ -127,18 +159,143 @@ class PlatoonBackupManager internal constructor( BackupArchive.stage(input, stagedDatabase) } try { - val restoredSettings = validateSelectedBackup { - BackupFormatPolicy.requireComplete(staged.formatVersion, staged.settings != null) - AppBackupSettingsCodec.decode(requireNotNull(staged.settings)).also { - validateDatabase(stagedDatabase, requireCurrentSchema = false) - } + val target = managerFor(staged) + try { + target.manager.restoreStagedComplete(stagedDatabase, staged) + target.installProfileMetadata() + activateRestoredProfile(target.manager.storageScope) + } catch (error: Exception) { + runCatching(target::rollbackProfileMetadata) + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error } - replaceRestoredState(stagedDatabase, restoredSettings) } finally { stagedDatabase.delete() } } + private fun restoreStagedPlatoon( + stagedDatabase: File, + staged: BackupArchive.StagedArchive, + ) { + validateSelectedBackup { + BackupFormatPolicy.requirePlatoonOnly(staged.formatVersion, staged.settings != null) + requireArchiveMatchesScope(staged) + validateDatabase(stagedDatabase, requireCurrentSchema = false) + } + replaceRestoredState(stagedDatabase, restoredSettings = null) + } + + private fun restoreStagedComplete( + stagedDatabase: File, + staged: BackupArchive.StagedArchive, + ) { + val restoredSettings = validateSelectedBackup { + BackupFormatPolicy.requireComplete(staged.formatVersion, staged.settings != null) + requireArchiveMatchesScope(staged) + AppBackupSettingsCodec.decode(requireNotNull(staged.settings)).also { + validateDatabase(stagedDatabase, requireCurrentSchema = false) + } + } + replaceRestoredState(stagedDatabase, restoredSettings) + } + + private fun managerFor(staged: BackupArchive.StagedArchive): RestoreTarget { + val registry = PlatoonProfileRegistry(appContext) + val restoredProfile = staged.profile?.toProfile() + restoredProfile?.let(registry::requireRestoreCapacity) + val previousProfile = restoredProfile?.let { registry.find(it.storageId) } + val clientRegions = ClientServerRegionPreferences(appContext) + val previousCaptureRegion = restoredProfile + ?.takeUnless(PlatoonProfile::legacy) + ?.let { clientRegions.get(it.client.packageName) } + val scope = restoredProfile?.storageId + ?.let(::PlatoonStorageScope) + ?: PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID) + val manager = if (scope == storageScope) { + this + } else { + PlatoonBackupManager( + context = appContext, + settingsStore = ScopedAppSettingsStore(appContext, scope.storageId), + storageScope = scope, + ) + } + return RestoreTarget( + manager = manager, + restoredProfile = restoredProfile, + previousProfile = previousProfile, + registry = registry, + clientRegions = clientRegions, + previousCaptureRegion = previousCaptureRegion, + ) + } + + private data class RestoreTarget( + val manager: PlatoonBackupManager, + val restoredProfile: PlatoonProfile?, + val previousProfile: PlatoonProfile?, + val registry: PlatoonProfileRegistry, + val clientRegions: ClientServerRegionPreferences, + val previousCaptureRegion: GameServerRegion?, + ) { + private var metadataInstalled = false + private var captureRegionInstalled = false + + fun installProfileMetadata() { + restoredProfile ?: return + registry.upsertRestored(restoredProfile) + metadataInstalled = true + if (!restoredProfile.legacy) { + clientRegions.set(restoredProfile.client.packageName, restoredProfile.serverRegion) + captureRegionInstalled = true + } + } + + fun rollbackProfileMetadata() { + val restored = restoredProfile ?: return + if (captureRegionInstalled) { + clientRegions.set( + restored.client.packageName, + requireNotNull(previousCaptureRegion), + ) + } + if (metadataInstalled) { + if (previousProfile == null) { + registry.removeIfInactive(restored.storageId) + } else { + registry.upsertRestored(previousProfile) + } + } + } + } + + private fun requireArchiveMatchesScope(staged: BackupArchive.StagedArchive) { + val archivedId = staged.profile?.storageId + if (archivedId == null) { + require(storageScope.isLegacy) { "Legacy backup must be restored to existing data" } + } else { + require(archivedId == storageScope.storageId) { + "Backup belongs to a different Platoon" + } + } + } + + private fun activateRestoredProfile(scope: PlatoonStorageScope) { + val registry = PlatoonProfileRegistry(appContext) + if (scope.isLegacy) registry.ensureLegacyProfile() else registry.ensureInitialized() + check(registry.setActive(scope.storageId)) { "Unable to select the restored Platoon" } + } + + private fun backupProfile(): PlatoonProfile { + val registry = PlatoonProfileRegistry(appContext) + if (storageScope.isLegacy) registry.ensureLegacyProfile() else registry.ensureInitialized() + return requireNotNull(registry.find(storageScope.storageId)) { + "The selected Platoon profile is unavailable" + } + } + // Function Name: validateSelectedBackup // Description: // - Converts expected archive, settings, and SQLite validation failures into a typed error. @@ -175,15 +332,15 @@ class PlatoonBackupManager internal constructor( retireRetainedCsv: Boolean = true, ) { val retainedCsvDirectory = File( - appContext.filesDir, + storageScope.rootDirectory(appContext), PlatoonRepository.RETAINED_CSV_DIRECTORY, ) val previousRetainedCsvDirectory = File( - appContext.filesDir, + storageScope.rootDirectory(appContext), "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", ) try { - PlatoonRepository.withExclusiveDatabase { + PlatoonRepository.withExclusiveDatabase(storageScope) { beginRestoreTransaction( previousSettings = if (restoredSettings == null) { null @@ -213,7 +370,9 @@ class PlatoonBackupManager internal constructor( ) } } catch (error: Exception) { - runCatching { recoverInterruptedFullRestore(appContext, settingsStore) } + runCatching { + recoverInterruptedFullRestore(appContext, settingsStore, storageScope) + } .exceptionOrNull() ?.let(error::addSuppressed) throw error @@ -308,7 +467,7 @@ class PlatoonBackupManager internal constructor( // Opening through the real helper upgrades old supported backups // and verifies that the installed database serves the current // schema before the rollback copy is discarded. - PlatoonDatabase(appContext).use { helper -> + PlatoonDatabase(appContext, storageScope.databaseName).use { helper -> helper.readableDatabase.rawQuery( "SELECT COUNT(*) FROM members", null, @@ -335,12 +494,12 @@ class PlatoonBackupManager internal constructor( private fun previousDatabaseFile() = File( databaseFile.parentFile, - "${PlatoonSchema.DATABASE_NAME}.pre_restore", + "${storageScope.databaseName}.pre_restore", ) private fun ensureDatabaseExists() { if (databaseFile.isFile) return - PlatoonDatabase(appContext).use { helper -> + PlatoonDatabase(appContext, storageScope.databaseName).use { helper -> helper.writableDatabase.rawQuery("SELECT COUNT(*) FROM members", null).use { cursor -> check(cursor.moveToFirst()) } @@ -368,24 +527,27 @@ class PlatoonBackupManager internal constructor( previousSettings: AppBackupSettings?, databaseExisted: Boolean, ) { - val transactionDirectory = restoreTransactionDirectory(appContext) + val transactionDirectory = restoreTransactionDirectory(appContext, storageScope) require(!transactionDirectory.exists()) { "A previous backup restore is still pending" } check(transactionDirectory.mkdirs()) { "Unable to create the backup-restore transaction" } if (previousSettings != null) { writeAtomic( - restoreSettingsFile(appContext), + restoreSettingsFile(appContext, storageScope), AppBackupSettingsCodec.encode(previousSettings), ) - writeAtomic(restoreSettingsRollbackFile(appContext), ByteArray(0)) + writeAtomic(restoreSettingsRollbackFile(appContext, storageScope), ByteArray(0)) } if (!databaseExisted) { - writeAtomic(restoreDatabaseWasMissingFile(appContext), ByteArray(0)) + writeAtomic(restoreDatabaseWasMissingFile(appContext, storageScope), ByteArray(0)) } writeRestoreState(RestoreState.PREPARED) } private fun writeRestoreState(state: RestoreState) { - writeAtomic(restoreStateFile(appContext), state.name.toByteArray(Charsets.US_ASCII)) + writeAtomic( + restoreStateFile(appContext, storageScope), + state.name.toByteArray(Charsets.US_ASCII), + ) } private fun cleanupCommittedRestore(previousCsv: File, previousDatabase: File) { @@ -393,7 +555,7 @@ class PlatoonBackupManager internal constructor( return } if (previousDatabase.exists() && !previousDatabase.delete()) return - cleanupRestoreTransaction(appContext) + cleanupRestoreTransaction(appContext, storageScope) } companion object { @@ -409,31 +571,80 @@ class PlatoonBackupManager internal constructor( internal fun recoverInterruptedFullRestore( context: Context, - settingsStore: BackupSettingsStore = AppSettingsStore(context.applicationContext), + settingsStore: BackupSettingsStore? = null, ) { val appContext = context.applicationContext - PlatoonRepository.withExclusiveDatabase { - val stateFile = restoreStateFile(appContext) + val profiles = PlatoonProfileRegistry(appContext).ensureInitialized() + val registeredScopes = profiles.map { PlatoonStorageScope(it.storageId) } + val interruptedScopes = File(appContext.filesDir, "platoons") + .listFiles() + .orEmpty() + .asSequence() + .filter(File::isDirectory) + .mapNotNull { directory -> + directory.name + .takeIf(PlatoonProfileIdentity::isValidStorageId) + ?.let(::PlatoonStorageScope) + } + .filter { scope -> restoreStateFile(appContext, scope).isFile } + .toList() + val scopes = buildList { + addAll(registeredScopes) + addAll(interruptedScopes) + val legacyScope = PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID) + if (isEmpty() || restoreStateFile(appContext, legacyScope).isFile) { + add(legacyScope) + } + } + scopes.distinct().forEach { scope -> + recoverInterruptedFullRestore( + appContext, + if (settingsStore != null && scope.isLegacy) { + settingsStore + } else { + ScopedAppSettingsStore(appContext, scope.storageId) + }, + scope, + ) + } + } + + internal fun recoverInterruptedFullRestore( + context: Context, + scope: PlatoonStorageScope, + ) = recoverInterruptedFullRestore( + context.applicationContext, + ScopedAppSettingsStore(context.applicationContext, scope.storageId), + scope, + ) + + private fun recoverInterruptedFullRestore( + appContext: Context, + settingsStore: BackupSettingsStore, + scope: PlatoonStorageScope, + ) { + PlatoonRepository.withExclusiveDatabase(scope) { + val stateFile = restoreStateFile(appContext, scope) if (!stateFile.isFile) { - cleanupRestoreTransaction(appContext) - cleanupLegacyRetiredCsvArtifacts(appContext) - recoverLegacyRetainedCsvRetirement(appContext) + cleanupRestoreTransaction(appContext, scope) + cleanupLegacyRetiredCsvArtifacts(appContext, scope) + recoverLegacyRetainedCsvRetirement(appContext, scope) return@withExclusiveDatabase } val state = runCatching { RestoreState.valueOf(stateFile.readText(Charsets.US_ASCII)) }.getOrElse { throw IllegalStateException("Invalid backup-restore transaction", it) } - val database = appContext.getDatabasePath(PlatoonSchema.DATABASE_NAME) + val database = appContext.getDatabasePath(scope.databaseName) val previousDatabase = File( database.parentFile, - "${PlatoonSchema.DATABASE_NAME}.pre_restore", + "${scope.databaseName}.pre_restore", ) val retainedCsv = File( - appContext.filesDir, + scope.rootDirectory(appContext), PlatoonRepository.RETAINED_CSV_DIRECTORY, ) val previousCsv = File( - appContext.filesDir, + scope.rootDirectory(appContext), "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", ) when (state) { @@ -444,11 +655,12 @@ class PlatoonBackupManager internal constructor( previousDatabase, retainedCsv, previousCsv, + scope, ) RestoreState.COMMITTED -> { if (previousCsv.exists() && !previousCsv.deleteRecursively()) return@withExclusiveDatabase if (previousDatabase.exists() && !previousDatabase.delete()) return@withExclusiveDatabase - cleanupRestoreTransaction(appContext) + cleanupRestoreTransaction(appContext, scope) } } } @@ -461,6 +673,7 @@ class PlatoonBackupManager internal constructor( previousDatabase: File, retainedCsv: File, previousCsv: File, + scope: PlatoonStorageScope, ) { if (previousDatabase.exists()) { databaseSidecars(database).forEach(File::delete) @@ -470,15 +683,15 @@ class PlatoonBackupManager internal constructor( if (!previousDatabase.renameTo(database)) { error("Unable to recover the previous Platoon database") } - } else if (restoreDatabaseWasMissingFile(context).isFile) { + } else if (restoreDatabaseWasMissingFile(context, scope).isFile) { databaseSidecars(database).forEach { file -> if (file.exists() && !file.delete()) { error("Unable to remove the interrupted restored database") } } } - val settingsFile = restoreSettingsFile(context) - val settingsRollback = restoreSettingsRollbackFile(context) + val settingsFile = restoreSettingsFile(context, scope) + val settingsRollback = restoreSettingsRollbackFile(context, scope) if (settingsRollback.isFile || settingsFile.isFile) { require(settingsFile.isFile) { "Previous app settings are missing" } settingsStore.replace(AppBackupSettingsCodec.decode(settingsFile.readBytes())) @@ -491,13 +704,19 @@ class PlatoonBackupManager internal constructor( error("Unable to recover retained CSV files") } } - cleanupRestoreTransaction(context) + cleanupRestoreTransaction(context, scope) } - private fun recoverLegacyRetainedCsvRetirement(context: Context) { - val directory = File(context.filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY) + private fun recoverLegacyRetainedCsvRetirement( + context: Context, + scope: PlatoonStorageScope, + ) { + val directory = File( + scope.rootDirectory(context), + PlatoonRepository.RETAINED_CSV_DIRECTORY, + ) val previous = File( - context.filesDir, + scope.rootDirectory(context), "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", ) if (!previous.exists()) return @@ -517,10 +736,15 @@ class PlatoonBackupManager internal constructor( // - context: Application context used to locate the private restore cache. // Returns: // - Returns normally after all obsolete artifacts are removed. - private fun cleanupLegacyRetiredCsvArtifacts(context: Context) { - val restoreDirectory = File(context.cacheDir, "platoon-restore") - restoreDirectory.listFiles() - .orEmpty() + private fun cleanupLegacyRetiredCsvArtifacts( + context: Context, + scope: PlatoonStorageScope, + ) { + val restoreDirectories = buildList { + add(File(context.cacheDir, "platoon-restore/${scope.storageId}")) + if (scope.isLegacy) add(File(context.cacheDir, "platoon-restore")) + } + restoreDirectories.flatMap { it.listFiles().orEmpty().asList() } .filter { file -> file.isDirectory && file.name.startsWith("guild-members.retired-") } @@ -545,13 +769,13 @@ class PlatoonBackupManager internal constructor( } } - private fun cleanupRestoreTransaction(context: Context) { - val directory = restoreTransactionDirectory(context) + private fun cleanupRestoreTransaction(context: Context, scope: PlatoonStorageScope) { + val directory = restoreTransactionDirectory(context, scope) if (!directory.exists()) return - val settings = restoreSettingsFile(context) - val settingsRollback = restoreSettingsRollbackFile(context) - val state = restoreStateFile(context) - val databaseWasMissing = restoreDatabaseWasMissingFile(context) + val settings = restoreSettingsFile(context, scope) + val settingsRollback = restoreSettingsRollbackFile(context, scope) + val state = restoreStateFile(context, scope) + val databaseWasMissing = restoreDatabaseWasMissingFile(context, scope) settings.delete() settingsRollback.delete() state.delete() @@ -559,20 +783,20 @@ class PlatoonBackupManager internal constructor( directory.delete() } - private fun restoreTransactionDirectory(context: Context) = - File(context.filesDir, RESTORE_TRANSACTION_DIRECTORY) + private fun restoreTransactionDirectory(context: Context, scope: PlatoonStorageScope) = + File(scope.rootDirectory(context), RESTORE_TRANSACTION_DIRECTORY) - private fun restoreStateFile(context: Context) = - File(restoreTransactionDirectory(context), RESTORE_STATE_FILE) + private fun restoreStateFile(context: Context, scope: PlatoonStorageScope) = + File(restoreTransactionDirectory(context, scope), RESTORE_STATE_FILE) - private fun restoreSettingsFile(context: Context) = - File(restoreTransactionDirectory(context), RESTORE_SETTINGS_FILE) + private fun restoreSettingsFile(context: Context, scope: PlatoonStorageScope) = + File(restoreTransactionDirectory(context, scope), RESTORE_SETTINGS_FILE) - private fun restoreSettingsRollbackFile(context: Context) = - File(restoreTransactionDirectory(context), RESTORE_SETTINGS_ROLLBACK_FILE) + private fun restoreSettingsRollbackFile(context: Context, scope: PlatoonStorageScope) = + File(restoreTransactionDirectory(context, scope), RESTORE_SETTINGS_ROLLBACK_FILE) - private fun restoreDatabaseWasMissingFile(context: Context) = - File(restoreTransactionDirectory(context), RESTORE_DATABASE_WAS_MISSING_FILE) + private fun restoreDatabaseWasMissingFile(context: Context, scope: PlatoonStorageScope) = + File(restoreTransactionDirectory(context, scope), RESTORE_DATABASE_WAS_MISSING_FILE) private fun databaseSidecars(database: File): List = listOf("", "-wal", "-shm", "-journal").map { suffix -> File(database.path + suffix) } 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 74affa7..bac5477 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -23,6 +23,7 @@ class PlatoonDatabase( PlatoonSchema.CURRENT_VERSION, ) { private val appContext = context.applicationContext + private val storageScope = PlatoonStorageScope.fromDatabaseName(databaseName) override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) @@ -232,7 +233,10 @@ class PlatoonDatabase( backfillSnapshotMembershipPeriodEvents(db) } if (needsManualCalendarDateBackfill) { - backfillManualCalendarDates(db, GameTimeZonePreferences.get(appContext)) + backfillManualCalendarDates( + db, + GameTimeZonePreferences.get(appContext, storageScope.storageId), + ) } if (oldVersion < 11) { createPlatoonMaintenanceStateTable(db) @@ -363,6 +367,12 @@ class PlatoonDatabase( db.execSQL("DROP TABLE member_events_legacy_v10") } + /** Returns true only when this database contains user-visible management state. */ + internal fun hasManagementData(): Boolean { + val db = readableDatabase + return MANAGEMENT_DATA_TABLES.any { table -> count(db, table) > 0L } + } + @Synchronized internal fun runInTransaction(block: () -> T): T { val db = writableDatabase @@ -2334,6 +2344,14 @@ class PlatoonDatabase( @Synchronized fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long { require(text.isNotBlank()) + val manualNoteCount = readableDatabase.rawQuery( + "SELECT COUNT(*) FROM weekly_notes WHERE period_start = ? AND is_automatic = 0", + arrayOf(periodStartEpochDay.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getInt(0) + } + if (!WeeklyNotePolicy.canAdd(manualNoteCount)) throw WeeklyNoteLimitException() return writableDatabase.insertOrThrow( "weekly_notes", null, @@ -2657,6 +2675,44 @@ class PlatoonDatabase( } } + // Function Name: replaceWeeklyReportHistory + // Description: + // - Replaces every derived weekly revision in one SQLite transaction. + // - Validates the complete replacement before deleting existing history. + // Parameters: + // - replacements: One prepared current revision for each evidence-backed week. + // Returns: + // - Unit after the complete replacement commits, or after SQLite rolls back on failure. + @Synchronized + internal fun replaceWeeklyReportHistory(replacements: List) { + require(replacements.map { it.periodStartEpochDay }.distinct().size == replacements.size) + replacements.forEach { replacement -> + require(replacement.fingerprint.matches(Regex("[0-9a-f]{64}"))) + require(replacement.payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) + } + val db = writableDatabase + db.beginTransaction() + try { + db.delete("weekly_report_history_state", null, null) + db.delete("weekly_report_history", null, null) + replacements.forEach { replacement -> + db.insertOrThrow( + "weekly_report_history", + null, + ContentValues().apply { + put("period_start", replacement.periodStartEpochDay) + put("recorded_at", replacement.recordedAt.toEpochMilli()) + put("fingerprint", replacement.fingerprint) + put("report_blob", replacement.payload) + }, + ) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + private fun createWeeklyOverridesTable(db: SQLiteDatabase) { db.execSQL( """ @@ -3644,13 +3700,13 @@ class PlatoonDatabase( instantIndex = 1, dateIndex = 3, timeKnownIndex = 5, - zoneId = GameTimeZonePreferences.get(appContext), + zoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId), ), left = cursor.membershipBoundaryValue( instantIndex = 2, dateIndex = 4, timeKnownIndex = 6, - zoneId = GameTimeZonePreferences.get(appContext), + zoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId), ), joinedSource = EvidenceSource.valueOf(cursor.getString(7)), leftSource = cursor.getNullableString(8)?.let(EvidenceSource::valueOf), @@ -3969,6 +4025,15 @@ class PlatoonDatabase( const val DAILY_PATROL_REWARD_ACTION_ID = 802001L internal const val MAX_STORED_ACTIVITY_OBSERVATIONS = 10_000 internal const val MAX_WEEKLY_REPORT_HISTORY = 15 + private val MANAGEMENT_DATA_TABLES = listOf( + "snapshots", + "members", + "membership_periods", + "member_events", + "platoon_activity", + "weekly_notes", + "weekly_overrides", + ) private const val MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS = 250 private const val ACTIVITY_RESOLUTION_CURSOR_KEY = "activity_resolution_cursor" private const val DAILY_PATROL_RELATED_ACTION_ID = 801005L @@ -4047,6 +4112,13 @@ class PlatoonDatabase( } } +internal data class WeeklyReportHistoryReplacement( + val periodStartEpochDay: Long, + val recordedAt: Instant, + val fingerprint: String, + val payload: ByteArray, +) + private fun ContentValues.putNullableLong(key: String, value: Long?) { if (value == null) putNull(key) else put(key, value) } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt new file mode 100644 index 0000000..5b4aef3 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -0,0 +1,340 @@ +package dev.gf2log.app.management + +import android.content.Context +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.protocol.model.PlatoonProfileData +import java.io.File +import java.security.MessageDigest +import java.time.Instant + +/** Publisher identity resolved from Android's original VPN flow ownership. */ +internal enum class PlatoonClient(val packageName: String, val displayName: String) { + HAOPLAY(SupportedGamePackages.HAOPLAY, "HaoPlay"), + DARKWINTER(SupportedGamePackages.DARKWINTER, "Darkwinter"), + LEGACY("legacy", "Existing data"), + ; + + companion object { + fun fromPackage(packageName: String?): PlatoonClient? = entries + .firstOrNull { it != LEGACY && it.packageName == packageName } + } +} + +/** Stable management scope for one publisher, server region, and Platoon ID. */ +internal data class PlatoonProfile( + val storageId: String, + val client: PlatoonClient, + val serverRegion: GameServerRegion, + val platoonId: Long, + val platoonName: String, + val emblemPrimary: List, + val emblemSecondary: List, + val lastSeenAt: Instant, + val legacy: Boolean = false, +) { + init { + require(PlatoonProfileIdentity.isValidStorageId(storageId)) + require(platoonId in 0L..UInt.MAX_VALUE.toLong()) + require( + platoonName.isNotBlank() && + platoonName.length <= MAX_NAME_LENGTH && + platoonName.none(Char::isISOControl), + ) + require(emblemPrimary.size <= MAX_EMBLEM_PARTS) + require(emblemSecondary.size <= MAX_EMBLEM_PARTS) + require(emblemPrimary.all { it in 0L..UInt.MAX_VALUE.toLong() }) + require(emblemSecondary.all { it in 0L..UInt.MAX_VALUE.toLong() }) + require(legacy == (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID)) + if (legacy) { + require(client == PlatoonClient.LEGACY) + require(serverRegion == GameServerRegion.MANUAL) + require(platoonId == 0L) + require(emblemPrimary.isEmpty() && emblemSecondary.isEmpty()) + } else { + require(client != PlatoonClient.LEGACY) + require(serverRegion != GameServerRegion.MANUAL) + require(platoonId > 0L) + require(storageId == PlatoonProfileIdentity.storageId(client, serverRegion, platoonId)) + } + } + + companion object { + const val MAX_EMBLEM_PARTS = 32 + const val MAX_NAME_LENGTH = 128 + } +} + +/** Deterministically maps an authoritative composite identity to a safe storage identifier. */ +internal object PlatoonProfileIdentity { + const val LEGACY_STORAGE_ID = "legacy" + private val STORAGE_ID = Regex("(?:legacy|[0-9a-f]{32})") + + fun storageId(client: PlatoonClient, region: GameServerRegion, platoonId: Long): String { + require(client != PlatoonClient.LEGACY) + require(region != GameServerRegion.MANUAL) + require(platoonId > 0L) + val material = "${client.packageName}\u0000${region.storedValue}\u0000$platoonId" + .toByteArray(Charsets.UTF_8) + return MessageDigest.getInstance("SHA-256") + .digest(material) + .take(16) + .joinToString("") { value -> "%02x".format(value) } + } + + fun isValidStorageId(value: String): Boolean = STORAGE_ID.matches(value) +} + +/** Resolves every database and retained-evidence path from one validated profile ID. */ +internal data class PlatoonStorageScope(val storageId: String) { + init { + require(PlatoonProfileIdentity.isValidStorageId(storageId)) + } + + val databaseName: String + get() = if (isLegacy) PlatoonSchema.DATABASE_NAME else "platoon-$storageId.db" + + val isLegacy: Boolean + get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID + + fun rootDirectory(context: Context): File = if (isLegacy) { + context.applicationContext.filesDir + } else { + File(context.applicationContext.filesDir, "platoons/$storageId") + } + + fun retainedCsvDirectory(context: Context): File = + File(rootDirectory(context), PlatoonRepository.RETAINED_CSV_DIRECTORY) + + companion object { + private val SCOPED_DATABASE = Regex("platoon-([0-9a-f]{32})\\.db") + + fun fromDatabaseName(databaseName: String): PlatoonStorageScope = when (databaseName) { + PlatoonSchema.DATABASE_NAME -> PlatoonStorageScope( + PlatoonProfileIdentity.LEGACY_STORAGE_ID, + ) + else -> SCOPED_DATABASE.matchEntire(databaseName) + ?.groupValues + ?.get(1) + ?.let(::PlatoonStorageScope) + ?: PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID) + } + } +} + +/** Persists the small profile registry independently from every isolated management database. */ +internal class PlatoonProfileRegistry(context: Context) { + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + fun ensureInitialized(): List = synchronized(lock) { + val current = readAllLocked() + if (current.isNotEmpty()) return@synchronized current + val legacyDatabase = appContext.getDatabasePath(PlatoonSchema.DATABASE_NAME) + val hasLegacyDatabaseData = legacyDatabase.isFile && runCatching { + PlatoonDatabase(appContext).use(PlatoonDatabase::hasManagementData) + }.getOrDefault(false) + val hasRetainedCsvData = File( + appContext.filesDir, + PlatoonRepository.RETAINED_CSV_DIRECTORY, + ).listFiles { file -> + file.isFile && file.length() > 0L && file.extension.equals("csv", ignoreCase = true) + }.orEmpty().isNotEmpty() + val hasLegacyData = hasLegacyDatabaseData || hasRetainedCsvData + if (!hasLegacyData) return@synchronized emptyList() + val legacy = legacyProfile() + writeLocked(legacy, setActive = true) + listOf(legacy) + } + + fun ensureLegacyProfile(): PlatoonProfile = synchronized(lock) { + readLocked(PlatoonProfileIdentity.LEGACY_STORAGE_ID)?.let { return@synchronized it } + val legacy = legacyProfile() + writeLocked(legacy, setActive = preferences.getString(KEY_ACTIVE, null) == null) + legacy + } + + fun list(): List = synchronized(lock) { + ensureInitialized() + readAllLocked().sortedWith( + compareByDescending { it.lastSeenAt }.thenBy { it.storageId }, + ) + } + + fun active(): PlatoonProfile? = synchronized(lock) { + ensureInitialized() + val activeId = preferences.getString(KEY_ACTIVE, null) + readAllLocked().firstOrNull { it.storageId == activeId } + ?: readAllLocked().maxByOrNull(PlatoonProfile::lastSeenAt) + } + + fun find(storageId: String): PlatoonProfile? = synchronized(lock) { + ensureInitialized() + readLocked(storageId) + } + + fun activeScope(): PlatoonStorageScope = + PlatoonStorageScope(active()?.storageId ?: PlatoonProfileIdentity.LEGACY_STORAGE_ID) + + fun setActive(storageId: String): Boolean = synchronized(lock) { + require(PlatoonProfileIdentity.isValidStorageId(storageId)) + if (readLocked(storageId) == null) return@synchronized false + preferences.edit().putString(KEY_ACTIVE, storageId).commit() + } + + fun upsertDetected( + ownerPackage: String, + region: GameServerRegion, + data: PlatoonProfileData, + observedAt: Instant = Instant.now(), + ): PlatoonProfile = synchronized(lock) { + val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) { + "Unsupported Platoon client package" + } + require(region != GameServerRegion.MANUAL) { "A server region is required" } + require(data.platoonId != 0u && data.platoonName.isNotBlank()) + val platoonId = data.platoonId.toLong() + val storageId = PlatoonProfileIdentity.storageId(client, region, platoonId) + require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { + "Too many Platoon profiles are already registered" + } + val normalizedName = data.platoonName + .replace(Regex("\\s+"), " ") + .filterNot(Char::isISOControl) + .trim() + .take(PlatoonProfile.MAX_NAME_LENGTH) + require(normalizedName.isNotBlank()) { "Platoon name is empty after normalization" } + val profile = PlatoonProfile( + storageId = storageId, + client = client, + serverRegion = region, + platoonId = platoonId, + platoonName = normalizedName, + emblemPrimary = data.emblemPrimary.take(PlatoonProfile.MAX_EMBLEM_PARTS).map(UInt::toLong), + emblemSecondary = data.emblemSecondary.take(PlatoonProfile.MAX_EMBLEM_PARTS).map(UInt::toLong), + lastSeenAt = observedAt, + ) + writeLocked(profile, setActive = preferences.getString(KEY_ACTIVE, null) == null) + profile + } + + fun upsertRestored(profile: PlatoonProfile): PlatoonProfile = synchronized(lock) { + if (!profile.legacy) { + require( + profile.storageId == PlatoonProfileIdentity.storageId( + profile.client, + profile.serverRegion, + profile.platoonId, + ), + ) { "Restored Platoon identity is inconsistent" } + } + require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { + "Too many Platoon profiles are already registered" + } + writeLocked(profile, setActive = false) + profile + } + + /** Rejects a new restore scope before any database or filesystem state is replaced. */ + fun requireRestoreCapacity(profile: PlatoonProfile) = synchronized(lock) { + require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { + "Too many Platoon profiles are already registered" + } + } + + /** Removes registry metadata introduced by a failed restore; scoped data is left untouched. */ + fun removeIfInactive(storageId: String): Boolean = synchronized(lock) { + require(PlatoonProfileIdentity.isValidStorageId(storageId)) + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) return@synchronized false + if (preferences.getString(KEY_ACTIVE, null) == storageId) return@synchronized false + val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + if (!ids.remove(storageId)) return@synchronized false + val prefix = "$KEY_PROFILE.$storageId." + preferences.edit() + .putStringSet(KEY_IDS, ids) + .remove(prefix + CLIENT) + .remove(prefix + REGION) + .remove(prefix + PLATOON_ID) + .remove(prefix + NAME) + .remove(prefix + EMBLEM_PRIMARY) + .remove(prefix + EMBLEM_SECONDARY) + .remove(prefix + LAST_SEEN) + .remove(prefix + LEGACY) + .commit() + } + + private fun readAllLocked(): List = preferences + .getStringSet(KEY_IDS, emptySet()) + .orEmpty() + .mapNotNull(::readLocked) + + private fun readLocked(storageId: String): PlatoonProfile? = runCatching { + if (!PlatoonProfileIdentity.isValidStorageId(storageId)) return@runCatching null + val prefix = "$KEY_PROFILE.$storageId." + PlatoonProfile( + storageId = storageId, + client = PlatoonClient.valueOf(requireNotNull(preferences.getString(prefix + CLIENT, null))), + serverRegion = GameServerRegion.fromStored(preferences.getString(prefix + REGION, null)), + platoonId = preferences.getLong(prefix + PLATOON_ID, -1L), + platoonName = requireNotNull(preferences.getString(prefix + NAME, null)), + emblemPrimary = parseLongList(preferences.getString(prefix + EMBLEM_PRIMARY, null)), + emblemSecondary = parseLongList(preferences.getString(prefix + EMBLEM_SECONDARY, null)), + lastSeenAt = Instant.ofEpochMilli(preferences.getLong(prefix + LAST_SEEN, 0L)), + legacy = preferences.getBoolean(prefix + LEGACY, false), + ) + }.getOrNull() + + private fun writeLocked(profile: PlatoonProfile, setActive: Boolean) { + val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + ids += profile.storageId + val prefix = "$KEY_PROFILE.${profile.storageId}." + val editor = preferences.edit() + .putStringSet(KEY_IDS, ids) + .putString(prefix + CLIENT, profile.client.name) + .putString(prefix + REGION, profile.serverRegion.storedValue) + .putLong(prefix + PLATOON_ID, profile.platoonId) + .putString(prefix + NAME, profile.platoonName) + .putString(prefix + EMBLEM_PRIMARY, profile.emblemPrimary.joinToString(",")) + .putString(prefix + EMBLEM_SECONDARY, profile.emblemSecondary.joinToString(",")) + .putLong(prefix + LAST_SEEN, profile.lastSeenAt.toEpochMilli()) + .putBoolean(prefix + LEGACY, profile.legacy) + if (setActive) editor.putString(KEY_ACTIVE, profile.storageId) + check(editor.commit()) { "Unable to persist the Platoon profile registry" } + } + + private fun parseLongList(value: String?): List = value.orEmpty() + .split(',') + .filter(String::isNotBlank) + .mapNotNull(String::toLongOrNull) + .take(PlatoonProfile.MAX_EMBLEM_PARTS) + + private fun legacyProfile() = PlatoonProfile( + storageId = PlatoonProfileIdentity.LEGACY_STORAGE_ID, + client = PlatoonClient.LEGACY, + serverRegion = GameServerRegion.MANUAL, + platoonId = 0L, + platoonName = LEGACY_NAME, + emblemPrimary = emptyList(), + emblemSecondary = emptyList(), + lastSeenAt = Instant.EPOCH, + legacy = true, + ) + + internal companion object { + internal const val MAX_PROFILES = 16 + private const val PREFERENCES = "platoon_profiles" + private const val KEY_IDS = "profile_ids" + private const val KEY_ACTIVE = "active_profile" + private const val KEY_PROFILE = "profile" + private const val CLIENT = "client" + private const val REGION = "region" + private const val PLATOON_ID = "platoon_id" + private const val NAME = "name" + private const val EMBLEM_PRIMARY = "emblem_primary" + private const val EMBLEM_SECONDARY = "emblem_secondary" + private const val LAST_SEEN = "last_seen" + private const val LEGACY = "legacy" + private const val LEGACY_NAME = "Existing platoon data" + private val lock = Any() + } +} 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 ad25688..f6ffd31 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -15,11 +15,15 @@ import java.time.ZoneId import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.withLock -class PlatoonRepository(context: Context) { +internal class PlatoonRepository( + context: Context, + internal val storageScope: PlatoonStorageScope = + PlatoonProfileRegistry(context).activeScope(), +) { private val appContext = context.applicationContext init { - PlatoonBackupManager.recoverInterruptedFullRestore(appContext) + PlatoonBackupManager.recoverInterruptedFullRestore(appContext, storageScope) } fun ingest( @@ -76,7 +80,7 @@ class PlatoonRepository(context: Context) { } fun reconcileRetainedCsvFiles( - directory: File = File(appContext.filesDir, RETAINED_CSV_DIRECTORY), + directory: File = storageScope.retainedCsvDirectory(appContext), ): ImportResult { val result = access { database -> database.runInTransaction { var imported = 0 @@ -186,15 +190,15 @@ class PlatoonRepository(context: Context) { } fun deleteMember(uid: Long): Boolean { - val deleted = withExclusiveDatabase { - val order = MemberOrderPreferences(appContext) + val deleted = withExclusiveDatabase(storageScope) { + val order = MemberOrderPreferences(appContext, storageScope.storageId) val previousOrder = order.read() val updatedOrder = previousOrder.filterNot { it == uid } if (updatedOrder != previousOrder) { check(order.write(updatedOrder)) { "Unable to update saved member order" } } try { - PlatoonDatabase(appContext).use { database -> + PlatoonDatabase(appContext, storageScope.databaseName).use { database -> val deleted = database.deleteMember(uid) if (!deleted && updatedOrder != previousOrder) { check(order.write(previousOrder)) { @@ -368,7 +372,7 @@ class PlatoonRepository(context: Context) { ) { access { it.replaceWeeklyOverrides(periodStartEpochDay, overrides) } val day = LocalDate.ofEpochDay(periodStartEpochDay) - val zone = GameTimeZonePreferences.get(appContext) + val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) recordLiveWeeklyRevisionSafely(day, zone) } @@ -385,18 +389,29 @@ class PlatoonRepository(context: Context) { fun showLiveWeeklyReport(periodStart: LocalDate): Boolean = access { it.clearActiveWeeklyReportHistory(periodStart.toEpochDay()) } - fun rebuildWeeklyHistoryForTimeZoneChange() { - withExclusiveDatabase { - PlatoonDatabase(appContext).use(PlatoonDatabase::clearWeeklyReportHistory) - } - recordAllLiveWeeklyReports(failFast = true) + fun rebuildWeeklyHistoryForTimeZoneChange(zoneId: ZoneId) { + val recordedAt = Instant.now() + val replacements = WeeklyReportRange + .periodStarts(access { it.listWeeklyEvidenceDays(zoneId) }) + .map { periodStart -> + val encoded = WeeklyReportHistoryCodec.encode( + buildLiveWeeklyRevision(periodStart, zoneId, recordedAt), + ) + WeeklyReportHistoryReplacement( + periodStartEpochDay = periodStart.toEpochDay(), + recordedAt = recordedAt, + fingerprint = encoded.fingerprint, + payload = encoded.payload, + ) + } + access { it.replaceWeeklyReportHistory(replacements) } } private fun recordChangedWeeks(instants: Iterable) = recordChangedWeeks(instants.asSequence()) private fun recordChangedWeeks(instants: Sequence) { - val zone = GameTimeZonePreferences.get(appContext) + val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) .forEach { periodStart -> recordLiveWeeklyRevisionSafely(periodStart, zone) @@ -410,7 +425,7 @@ class PlatoonRepository(context: Context) { // Parameters: // - failFast: Propagates failures for explicit maintenance operations such as timezone rebuilds. private fun recordAllLiveWeeklyReports(failFast: Boolean = false) { - val zone = GameTimeZonePreferences.get(appContext) + val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) WeeklyReportRange.periodStarts(access { it.listWeeklyEvidenceDays(zone) }) .forEach { periodStart -> if (failFast) { @@ -444,7 +459,7 @@ class PlatoonRepository(context: Context) { } private fun recordLiveWeeklyRevision(periodStart: LocalDate) { - val zone = GameTimeZonePreferences.get(appContext) + val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) recordLiveWeeklyRevisionSafely(periodStart, zone) } @@ -480,7 +495,7 @@ class PlatoonRepository(context: Context) { } private fun access(block: (PlatoonDatabase) -> T): T = - withDatabase(appContext, block) + withDatabase(appContext, storageScope, block) data class ImportResult( val imported: Int, @@ -512,26 +527,33 @@ class PlatoonRepository(context: Context) { private val databaseLock = Any() private val maintenanceLock = ReentrantReadWriteLock(true) - @Volatile - private var databaseInstance: PlatoonDatabase? = null + private val databaseInstances = mutableMapOf() - private fun database(context: Context): PlatoonDatabase = - databaseInstance ?: synchronized(databaseLock) { - databaseInstance ?: PlatoonDatabase(context).also { databaseInstance = it } + private fun database(context: Context, scope: PlatoonStorageScope): PlatoonDatabase = + synchronized(databaseLock) { + databaseInstances[scope.databaseName] + ?: PlatoonDatabase(context, scope.databaseName).also { + databaseInstances[scope.databaseName] = it + } } private fun withDatabase( context: Context, + scope: PlatoonStorageScope, block: (PlatoonDatabase) -> T, ): T = maintenanceLock.readLock().withLock { - block(database(context)) + block(database(context, scope)) } - internal fun withExclusiveDatabase(block: () -> T): T = + internal fun withExclusiveDatabase( + scope: PlatoonStorageScope = PlatoonStorageScope( + PlatoonProfileIdentity.LEGACY_STORAGE_ID, + ), + block: () -> T, + ): T = maintenanceLock.writeLock().withLock { synchronized(databaseLock) { - databaseInstance?.close() - databaseInstance = null + databaseInstances.remove(scope.databaseName)?.close() } block() } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyMemberNameProjection.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyMemberNameProjection.kt new file mode 100644 index 0000000..a331c36 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyMemberNameProjection.kt @@ -0,0 +1,9 @@ +package dev.gf2log.app.management + +/** Merges report fallbacks without replacing custom names captured in revision context. */ +internal object WeeklyMemberNameProjection { + fun merge( + reportNamesByUid: Map, + capturedNamesByUid: Map, + ): Map = reportNamesByUid + capturedNamesByUid +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt new file mode 100644 index 0000000..f5da951 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt @@ -0,0 +1,15 @@ +package dev.gf2log.app.management + +/** Keeps live weekly notes representable by the immutable weekly-history format. */ +internal object WeeklyNotePolicy { + const val MAX_MANUAL_NOTES_PER_WEEK = 128 + + fun canAdd(manualNoteCount: Int): Boolean = + manualNoteCount in 0 until MAX_MANUAL_NOTES_PER_WEEK +} + +/** Signals the one user-correctable weekly-note capacity failure. */ +internal class WeeklyNoteLimitException : IllegalArgumentException( + "A weekly table cannot contain more than " + + "${WeeklyNotePolicy.MAX_MANUAL_NOTES_PER_WEEK} manual notes", +) diff --git a/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt b/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt index 051ca6b..03a4e7a 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt @@ -16,3 +16,48 @@ internal class AppSettingsStore(context: Context) : BackupSettingsStore { override fun replace(settings: AppBackupSettings) = UserSettingsPreferences.replace(appContext, settings) } + +/** Projects global presentation settings plus one Platoon's isolated reporting settings. */ +internal class ScopedAppSettingsStore( + context: Context, + private val storageId: String, +) : BackupSettingsStore { + private val appContext = context.applicationContext + + override fun read(): AppBackupSettings { + val global = UserSettingsPreferences.read(appContext) + return global.copy( + gameServerRegion = GameTimeZonePreferences.region(appContext, storageId).storedValue, + gameTimeZoneId = GameTimeZonePreferences.get(appContext, storageId).id, + memberOrder = MemberOrderPreferences(appContext, storageId).read(), + weeklyCutlines = WeeklyCutlinePreferences(appContext, storageId).read(), + ) + } + + override fun replace(settings: AppBackupSettings) { + val previousGlobal = UserSettingsPreferences.read(appContext) + UserSettingsPreferences.replace( + appContext, + settings.copy( + gameServerRegion = previousGlobal.gameServerRegion, + gameTimeZoneId = previousGlobal.gameTimeZoneId, + memberOrder = previousGlobal.memberOrder, + weeklyCutlines = previousGlobal.weeklyCutlines, + ), + ) + val region = GameServerRegion.fromStored(settings.gameServerRegion) + if (region == GameServerRegion.MANUAL) { + GameTimeZonePreferences.set( + appContext, + java.time.ZoneId.of(settings.gameTimeZoneId), + storageId, + ) + } else { + GameTimeZonePreferences.setRegion(appContext, region, storageId) + } + check(MemberOrderPreferences(appContext, storageId).write(settings.memberOrder)) { + "Unable to restore the Platoon member order" + } + WeeklyCutlinePreferences(appContext, storageId).write(settings.weeklyCutlines) + } +} diff --git a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt new file mode 100644 index 0000000..0e2cd18 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt @@ -0,0 +1,52 @@ +package dev.gf2log.app.settings + +import android.content.Context +import dev.gf2log.app.SupportedGamePackages + +/** Stores the server region used to route each supported publisher's captured profile. */ +internal class ClientServerRegionPreferences(context: Context) { + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + fun get(packageName: String): GameServerRegion { + require(packageName in SupportedGamePackages.all) + val stored = GameServerRegion.fromStored(preferences.getString(packageName, null)) + if (stored in allowed(packageName)) return stored + + val legacy = GameTimeZonePreferences.legacyRegion(appContext) + return legacy.takeIf { it in allowed(packageName) } ?: default(packageName) + } + + fun set(packageName: String, region: GameServerRegion) { + require(region in allowed(packageName)) + check(preferences.edit().putString(packageName, region.storedValue).commit()) { + "Unable to persist the client server region" + } + } + + fun allowed(packageName: String): List = allowedFor(packageName) + + private fun default(packageName: String): GameServerRegion = when (packageName) { + SupportedGamePackages.HAOPLAY -> GameServerRegion.HAOPLAY_KOREA + SupportedGamePackages.DARKWINTER -> GameServerRegion.DARKWINTER_GLOBAL + else -> error("Unsupported game package") + } + + internal companion object { + const val PREFERENCES = "capture_server_regions" + + fun allowedFor(packageName: String): List = when (packageName) { + SupportedGamePackages.HAOPLAY -> listOf( + GameServerRegion.HAOPLAY_GLOBAL, + GameServerRegion.HAOPLAY_JAPAN, + GameServerRegion.HAOPLAY_KOREA, + GameServerRegion.HAOPLAY_ASIA, + ) + SupportedGamePackages.DARKWINTER -> listOf( + GameServerRegion.DARKWINTER_GLOBAL, + GameServerRegion.DARKWINTER_CHINA, + ) + else -> error("Unsupported game package") + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt index cb79582..540c13c 100644 --- a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt @@ -1,28 +1,77 @@ package dev.gf2log.app.settings import android.content.Context +import dev.gf2log.app.management.PlatoonProfileIdentity +import dev.gf2log.app.management.PlatoonProfileRegistry import java.time.ZoneId /** Provides the one persisted timezone used for every game-day and weekly boundary. */ internal object GameTimeZonePreferences { - fun get(context: Context): ZoneId { - val region = region(context) + fun get( + context: Context, + storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + ): ZoneId { + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) return legacyZone(context) + val region = region(context, storageId) return region.serverZone ?: runCatching { - ZoneId.of(UserSettingsPreferences.gameTimeZoneId(context)) + ZoneId.of(scoped(context).getString(zoneKey(storageId), ZoneId.systemDefault().id)) }.getOrElse { ZoneId.systemDefault() } } fun deviceZone(): ZoneId = ZoneId.systemDefault() - fun region(context: Context): GameServerRegion = GameServerRegion.fromStored( + fun region( + context: Context, + storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + ): GameServerRegion { + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) return legacyRegion(context) + val stored = scoped(context).getString(regionKey(storageId), null) + if (stored != null) return GameServerRegion.fromStored(stored) + return PlatoonProfileRegistry(context).find(storageId)?.serverRegion ?: GameServerRegion.MANUAL + } + + fun setRegion( + context: Context, + region: GameServerRegion, + storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + ) { + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { + UserSettingsPreferences.setGameServerRegion(context, region.storedValue) + } else { + check(scoped(context).edit().putString(regionKey(storageId), region.storedValue).commit()) + } + } + + fun set( + context: Context, + zoneId: ZoneId, + storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + ) { + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { + UserSettingsPreferences.setGameTimeZoneId(context, zoneId.id) + } else { + check(scoped(context).edit().putString(zoneKey(storageId), zoneId.id).commit()) + } + setRegion(context, GameServerRegion.MANUAL, storageId) + } + + fun legacyRegion(context: Context): GameServerRegion = GameServerRegion.fromStored( UserSettingsPreferences.gameServerRegion(context), ) - fun setRegion(context: Context, region: GameServerRegion) = - UserSettingsPreferences.setGameServerRegion(context, region.storedValue) - - fun set(context: Context, zoneId: ZoneId) { - UserSettingsPreferences.setGameTimeZoneId(context, zoneId.id) - setRegion(context, GameServerRegion.MANUAL) + private fun legacyZone(context: Context): ZoneId { + val region = legacyRegion(context) + return region.serverZone ?: runCatching { + ZoneId.of(UserSettingsPreferences.gameTimeZoneId(context)) + }.getOrElse { ZoneId.systemDefault() } } + + private fun scoped(context: Context) = context.applicationContext.getSharedPreferences( + PROFILE_PREFERENCES, + Context.MODE_PRIVATE, + ) + + private fun regionKey(storageId: String) = "region.$storageId" + private fun zoneKey(storageId: String) = "zone.$storageId" + private const val PROFILE_PREFERENCES = "platoon_timezones" } diff --git a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt index 69b3ecb..4ee16ef 100644 --- a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt @@ -1,17 +1,39 @@ package dev.gf2log.app.settings import android.content.Context +import dev.gf2log.app.management.PlatoonProfileIdentity +import dev.gf2log.app.management.PlatoonProfileRegistry -class MemberOrderPreferences(context: Context) { +class MemberOrderPreferences( + context: Context, + private val storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, +) { private val appContext = context.applicationContext + private val scoped = appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE) - fun read(): List = UserSettingsPreferences.memberOrder(appContext) + fun read(): List = if (isLegacy) { + UserSettingsPreferences.memberOrder(appContext) + } else { + scoped.getString(storageId, null).orEmpty() + .split(',') + .mapNotNull(String::toLongOrNull) + .filter { it > 0L } + .distinct() + } - fun write(uids: List): Boolean = + fun write(uids: List): Boolean = if (isLegacy) { UserSettingsPreferences.setMemberOrder(appContext, uids) + } else { + scoped.edit().putString(storageId, uids.filter { it > 0L }.distinct().joinToString(",")) + .commit() + } fun clear() { - UserSettingsPreferences.clearMemberOrder(appContext) + if (isLegacy) { + UserSettingsPreferences.clearMemberOrder(appContext) + } else { + scoped.edit().remove(storageId).apply() + } } fun apply(items: List, uid: (T) -> Long): List { @@ -22,4 +44,11 @@ class MemberOrderPreferences(context: Context) { .thenBy { items.indexOf(it) }, ) } + + private val isLegacy: Boolean + get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID + + private companion object { + const val PROFILE_PREFERENCES = "platoon_member_order" + } } diff --git a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt index f15a3f0..b7ff06b 100644 --- a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.settings import android.content.Context +import dev.gf2log.app.management.PlatoonProfileIdentity +import dev.gf2log.app.management.PlatoonProfileRegistry data class WeeklyCutlines( val dailyMerit: Long? = null, @@ -37,12 +39,73 @@ data class WeeklyCutlines( weeklyPatrolDays?.let { value < it } == true } -class WeeklyCutlinePreferences(context: Context) { +class WeeklyCutlinePreferences( + context: Context, + private val storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, +) { private val appContext = context.applicationContext + private val scoped = appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE) - fun read(): WeeklyCutlines = UserSettingsPreferences.weeklyCutlines(appContext) + fun read(): WeeklyCutlines = if (isLegacy) { + UserSettingsPreferences.weeklyCutlines(appContext) + } else { + WeeklyCutlines( + dailyMerit = scoped.optionalLong(key(DAILY_MERIT)), + dailyGunsmokeScore = scoped.optionalLong(key(DAILY_SCORE)), + dailyGunsmokeAttempts = scoped.optionalInt(key(DAILY_ATTEMPTS)), + weeklyMerit = scoped.optionalLong(key(WEEKLY_MERIT)), + weeklyGunsmokeScore = scoped.optionalLong(key(WEEKLY_SCORE)), + weeklyGunsmokeAttempts = scoped.optionalInt(key(WEEKLY_ATTEMPTS)), + weeklyLoginDays = scoped.optionalInt(key(WEEKLY_LOGIN)), + weeklyPatrolDays = scoped.optionalInt(key(WEEKLY_PATROL)), + ) + } fun write(cutlines: WeeklyCutlines) { - UserSettingsPreferences.setWeeklyCutlines(appContext, cutlines) + if (isLegacy) { + UserSettingsPreferences.setWeeklyCutlines(appContext, cutlines) + return + } + val editor = scoped.edit() + editor.putOptionalLong(key(DAILY_MERIT), cutlines.dailyMerit) + editor.putOptionalLong(key(DAILY_SCORE), cutlines.dailyGunsmokeScore) + editor.putOptionalInt(key(DAILY_ATTEMPTS), cutlines.dailyGunsmokeAttempts) + editor.putOptionalLong(key(WEEKLY_MERIT), cutlines.weeklyMerit) + editor.putOptionalLong(key(WEEKLY_SCORE), cutlines.weeklyGunsmokeScore) + editor.putOptionalInt(key(WEEKLY_ATTEMPTS), cutlines.weeklyGunsmokeAttempts) + editor.putOptionalInt(key(WEEKLY_LOGIN), cutlines.weeklyLoginDays) + editor.putOptionalInt(key(WEEKLY_PATROL), cutlines.weeklyPatrolDays) + check(editor.commit()) { "Unable to persist weekly cutlines" } + } + + private val isLegacy: Boolean + get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID + + private fun key(name: String) = "$storageId.$name" + + private fun android.content.SharedPreferences.optionalLong(key: String): Long? = + if (contains(key)) getLong(key, 0L) else null + + private fun android.content.SharedPreferences.optionalInt(key: String): Int? = + if (contains(key)) getInt(key, 0) else null + + private fun android.content.SharedPreferences.Editor.putOptionalLong(key: String, value: Long?) { + if (value == null) remove(key) else putLong(key, value) + } + + private fun android.content.SharedPreferences.Editor.putOptionalInt(key: String, value: Int?) { + if (value == null) remove(key) else putInt(key, value) + } + + private companion object { + const val PROFILE_PREFERENCES = "platoon_weekly_cutlines" + const val DAILY_MERIT = "daily_merit" + const val DAILY_SCORE = "daily_score" + const val DAILY_ATTEMPTS = "daily_attempts" + const val WEEKLY_MERIT = "weekly_merit" + const val WEEKLY_SCORE = "weekly_score" + const val WEEKLY_ATTEMPTS = "weekly_attempts" + const val WEEKLY_LOGIN = "weekly_login" + const val WEEKLY_PATROL = "weekly_patrol" } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bb49f0c..b23f4c4 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -25,7 +25,7 @@ 설정 열기 서클 관리 백업 내보내기 서클 관리 백업 가져오기 - 현재의 구조화된 서클 기록, 가입·탈퇴 시각, 비고를 대체합니다. 캡처를 중지한 상태로 유지하세요. + 백업에 포함된 서클의 관리 데이터를 대체합니다. 다른 서클의 데이터는 변경하지 않습니다. 캡처를 중지한 상태로 유지하세요. 백업을 내보내거나 가져오기 전에 캡처를 중지하세요. 서클 관리 백업을 내보냈습니다 서클 관리 백업을 복원했습니다 @@ -59,10 +59,10 @@ 전체 백업을 만들거나 복원합니다. Discord 웹훅을 설정하고 필요한 패킷 종류를 선택합니다. 서클 관리 - 캡처하거나 불러온 멤버 데이터가 멤버 정보 및 주간 표에 반영됩니다. + 캡처하거나 불러온 멤버 데이터가 선택한 서클의 멤버 정보 및 주간 표에 반영됩니다. 현재 가입 중이거나 과거에 탈퇴한 멤버의 정보와 가입·탈퇴 이력을 검색하고 관리합니다. CSV를 적용하기 전에 변경 내용을 미리 보고, 자동 체크포인트로 최근 가져오기를 되돌릴 수 있습니다. - 스냅샷을 비교하거나 선택한 멤버만 내보낼 수 있습니다. + 분리된 서클을 전환하고, 스냅샷을 비교하거나 선택한 멤버만 내보낼 수 있습니다. 주간 테이블 일반 주간과 흙먼지 주간이 구분된 주간 공적표를 확인합니다. 각 셀을 눌러 해당 값의 근거와 사용된 패킷 정보를 확인할 수 있습니다. @@ -75,10 +75,10 @@ 패킷에 담겨 있는 여러 정보를 통해 멤버 및 주간 테이블을 완성해 나갈 수 있습니다. 백업 백업 불러오기 - 전체 설정, 서클 관리와 멤버별 가입 기록을 포함한 상세 정보, 모든 주간 과업표를 전체 백업 파일에서 불러옵니다. + 전체 백업에 포함된 서클의 모든 관리 데이터와 앱 설정을 복원합니다. 다른 서클은 변경하지 않습니다. 전체 정보 백업하기 - 전체 설정, 서클 관리와 멤버별 가입 기록을 포함한 상세 정보, 모든 주간 과업표를 하나의 전체 백업 파일에 저장합니다. - 현재의 전체 설정, 서클 관리 정보, 멤버와 가입 기록, 모든 주간 과업표 기록을 대체합니다. 복원하는 동안 캡처를 중지하세요. + 앱 설정과 선택한 서클의 가입 이력 및 주간표를 포함한 모든 관리 데이터를 저장합니다. + 앱 설정과 백업에 포함된 서클 데이터를 대체한 뒤 해당 서클을 선택합니다. 다른 서클은 변경하지 않습니다. 복원하는 동안 캡처를 중지하세요. 올바른 전체 백업 파일이 아닙니다. 전체 정보 백업을 저장했습니다 전체 정보 백업을 복원했습니다 @@ -181,6 +181,7 @@ 불명 선택한 게임 날짜의 비고 추가 비고 추가 + 이번 주에는 최대 128개의 비고가 이미 저장되어 있습니다. 비고 삭제 이 비고를 삭제할까요? 삭제 @@ -408,4 +409,10 @@ 기록된 주간 테이블을 복원했습니다. 이 표 히스토리 항목을 더 이상 사용할 수 없습니다. 복원한 과거 주간 표를 표시하고 있습니다. 새 근거가 들어오면 최신 표로 돌아갑니다. + 서클 선택 + 감지된 서클 없음 + 지원 클라이언트에서 서클 프로필 패킷이 도착할 때까지 캡처하세요. + 기존 서클 데이터 + HaoPlay 캡처 서버 + Darkwinter 캡처 서버 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e8ee73..e0c3364 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ Open payload options Export Platoon backup Import Platoon backup - This replaces current structured Platoon history, membership dates, and notes. Capture must remain stopped. + This replaces management data for the Platoon contained in the backup. Other Platoons are unchanged. Capture must remain stopped. Stop capture before exporting or importing a backup. Platoon backup exported Platoon backup restored @@ -59,10 +59,10 @@ Create or restore a complete backup. Configure a Discord webhook and select the packet types you need. Platoon management - Captured or imported member data is reflected in member records and the weekly table. + Captured or imported member data is reflected in the selected Platoon’s member records and weekly table. Search and manage current or withdrawn members and their join and withdrawal history. Preview CSV imports before commit and use the automatic checkpoint to undo the latest import. - Compare snapshots or export only the members you select. + Switch between isolated Platoons, compare snapshots, or export only selected members. Weekly table Review weekly merit tables separated into Standard and Gunsmoke weeks. Tap any cell to see the basis for its value and the packet evidence used. @@ -75,10 +75,10 @@ Use the information in each packet to complete member records and the weekly table over time. Backup Restore backup - Restore all app settings, Platoon management and member details including membership history, and every weekly table from a complete backup. + Restore app settings and all management data for the Platoon contained in a complete backup. Other Platoons remain unchanged. Back up all information - Save all app settings, Platoon management and member details including membership history, and every weekly table in one complete backup. - This replaces all current app settings, Platoon management data, member and membership history, and weekly-table history. Keep capture stopped while restoring. + Save app settings and all management data for the selected Platoon, including membership history and weekly tables. + This replaces app settings and data for the Platoon contained in the backup, then selects it. Other Platoons remain unchanged. Keep capture stopped while restoring. This is not a valid complete backup file. Complete backup saved Complete backup restored @@ -181,6 +181,7 @@ unknown Add a note for the selected game day Add note + This week already contains the maximum 128 notes. Delete note Delete this note? Delete @@ -412,4 +413,10 @@ The recorded weekly table was restored. This table history entry is no longer available. Showing a restored table history entry. New evidence will return this week to the live table. + Select platoon + No platoon detected + Capture a supported client until the platoon profile arrives. + Existing platoon data + HaoPlay capture server + Darkwinter capture server diff --git a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt new file mode 100644 index 0000000..5162e8a --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt @@ -0,0 +1,31 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BoundedFlowPayloadBufferTest { + @Test + fun overflowDiscardsAndRejectsUntilFlowRemoval() { + val buffer = BoundedFlowPayloadBuffer(2) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "a")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "b")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(7, "c")) + assertTrue(buffer.take(7).isEmpty()) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.REJECTED, buffer.offer(7, "d")) + + buffer.remove(7) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "e")) + assertEquals(listOf("e"), buffer.take(7)) + } + + @Test + fun identityTakesOneFlowWithoutTouchingAnother() { + val buffer = BoundedFlowPayloadBuffer(2) + buffer.offer(1, "one") + buffer.offer(2, "two") + + assertEquals(listOf("one"), buffer.take(1)) + assertEquals(listOf("two"), buffer.take(2)) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowOwnerPolicyTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowOwnerPolicyTest.kt new file mode 100644 index 0000000..5c2bb29 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowOwnerPolicyTest.kt @@ -0,0 +1,32 @@ +package dev.gf2log.app.capture + +import dev.gf2log.app.SupportedGamePackages +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CaptureFlowOwnerPolicyTest { + @Test + fun resolvedSupportedOwnerWinsWhenBothClientsAreInstalled() { + assertEquals( + SupportedGamePackages.DARKWINTER, + CaptureFlowOwnerPolicy.choose( + SupportedGamePackages.DARKWINTER, + SupportedGamePackages.all, + ), + ) + } + + @Test + fun oneInstalledClientIsSafeFallback() { + assertEquals( + SupportedGamePackages.HAOPLAY, + CaptureFlowOwnerPolicy.choose(null, listOf(SupportedGamePackages.HAOPLAY)), + ) + } + + @Test + fun twoInstalledClientsRemainUnattributedWithoutOsEvidence() { + assertNull(CaptureFlowOwnerPolicy.choose(null, SupportedGamePackages.all)) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt new file mode 100644 index 0000000..0415250 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt @@ -0,0 +1,29 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class CaptureFlowStateCleanupTest { + @Test + fun metadataIsRemovedWhenFlowNeverCreatedAParser() { + val parsers = mutableMapOf() + val metadata = mutableMapOf(7L to metadata()) + + assertNull(CaptureFlowStateCleanup.remove(7L, parsers, metadata)) + assertFalse(metadata.containsKey(7L)) + } + + @Test + fun parserAndMetadataAreRemovedTogether() { + val parsers = mutableMapOf(7L to "parser") + val metadata = mutableMapOf(7L to metadata()) + + assertEquals("parser", CaptureFlowStateCleanup.remove(7L, parsers, metadata)) + assertFalse(parsers.containsKey(7L)) + assertFalse(metadata.containsKey(7L)) + } + + private fun metadata() = CaptureFlowMetadata(6, "10.0.0.2", 1, "10.0.0.3", 2, null) +} diff --git a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt new file mode 100644 index 0000000..69505c0 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt @@ -0,0 +1,21 @@ +package dev.gf2log.app.capture + +import dev.gf2log.protocol.model.PlatoonProfileData +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlatoonProfilePolicyTest { + @Test + fun onlyNonzeroNamedProfilesAreAuthoritative() { + assertFalse(PlatoonProfilePolicy.isValid(null)) + assertFalse(PlatoonProfilePolicy.isValid(profile(0u, "Owls"))) + assertFalse(PlatoonProfilePolicy.isValid(profile(101817u, " "))) + assertFalse(PlatoonProfilePolicy.isValid(profile(101817u, "Owls\u0000"))) + assertFalse(PlatoonProfilePolicy.isValid(profile(101817u, "x".repeat(129)))) + assertTrue(PlatoonProfilePolicy.isValid(profile(101817u, "Owls"))) + } + + private fun profile(id: UInt, name: String) = + PlatoonProfileData(id, name, emptyList(), emptyList()) +} diff --git a/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt b/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt new file mode 100644 index 0000000..35c4e38 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt @@ -0,0 +1,38 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScopedCaptureChecklistTest { + @Test + fun payloadsFromDifferentProfilesDoNotCompleteOneGuidedCapture() { + val checklist = ScopedCaptureChecklist(setOf(1, 2, 3, 4)) + + checklist.mark("haoplay", 1, chooseTarget = true) + checklist.mark("haoplay", 2, chooseTarget = true) + checklist.mark("darkwinter", 3, chooseTarget = true) + checklist.mark("darkwinter", 4, chooseTarget = true) + + assertEquals("haoplay", checklist.targetScopeId()) + assertFalse(checklist.targetComplete()) + + checklist.mark("haoplay", 3, chooseTarget = true) + checklist.mark("haoplay", 4, chooseTarget = true) + + assertTrue(checklist.targetComplete()) + } + + @Test + fun clearRemovesTargetAndCapturedEvidence() { + val checklist = ScopedCaptureChecklist(setOf(1)) + checklist.mark("profile", 1, chooseTarget = true) + + checklist.clear() + + assertEquals(null, checklist.targetScopeId()) + assertTrue(checklist.targetCaptured().isEmpty()) + assertFalse(checklist.targetComplete()) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt b/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt index 24b1cb2..31b7db7 100644 --- a/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import dev.gf2log.app.settings.GameServerRegion import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -14,6 +15,37 @@ import org.junit.Assert.assertThrows import org.junit.Test class BackupArchiveTest { + @Test + fun `round trips a scoped profile and verifies its deterministic identity`() { + val database = temporaryFile("platoon.db", realisticDatabaseBytes()) + val profile = PlatoonProfile( + storageId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ), + client = PlatoonClient.HAOPLAY, + serverRegion = GameServerRegion.HAOPLAY_KOREA, + platoonId = 101817L, + platoonName = "Owls 서클", + emblemPrimary = listOf(1, 2), + emblemSecondary = listOf(3), + lastSeenAt = java.time.Instant.EPOCH, + ) + val archive = ByteArrayOutputStream().also { + BackupArchive.write(it, database, realisticSettingsBytes(), profile) + } + + val result = BackupArchive.stage( + ByteArrayInputStream(archive.toByteArray()), + temporaryPath("scoped.db"), + ) + + assertEquals(BackupFormatPolicy.SCOPED_VERSION, result.formatVersion) + assertEquals(profile.storageId, result.profile?.storageId) + assertEquals(profile.platoonName, result.profile?.platoonName) + } + @Test fun `round trips a realistic complete archive without changing payload bytes`() { val database = temporaryFile("platoon.db", realisticDatabaseBytes()) diff --git a/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt index f222ad5..6b524dd 100644 --- a/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt @@ -10,6 +10,10 @@ class BackupFormatPolicyTest { BackupFormatPolicy.PLATOON_ONLY_VERSION, hasSettings = false, ) + BackupFormatPolicy.requirePlatoonOnly( + BackupFormatPolicy.SCOPED_VERSION, + hasSettings = false, + ) assertThrows(IllegalArgumentException::class.java) { BackupFormatPolicy.requirePlatoonOnly( @@ -25,6 +29,10 @@ class BackupFormatPolicyTest { BackupFormatPolicy.COMPLETE_VERSION, hasSettings = true, ) + BackupFormatPolicy.requireComplete( + BackupFormatPolicy.SCOPED_VERSION, + hasSettings = true, + ) assertThrows(IllegalArgumentException::class.java) { BackupFormatPolicy.requireComplete( diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt index 64c252a..ee851cd 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt @@ -96,6 +96,30 @@ class MembershipConsistencyPolicyTest { assertNull(MembershipConsistencyPolicy.violation(listOf(first, second))) } + @Test + fun sameDayKnownStartsAreOrderedByInstantInsteadOfInsertionId() { + val laterInsertedFirst = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-05-04T18:00:00Z"), + leftAt = Instant.parse("2026-05-04T20:00:00Z"), + joinedDate = LocalDate.of(2026, 5, 4), + leftDate = LocalDate.of(2026, 5, 4), + ) + val earlierInsertedSecond = MembershipInterval( + id = 2, + joinedAt = Instant.parse("2026-05-04T08:00:00Z"), + leftAt = Instant.parse("2026-05-04T10:00:00Z"), + joinedDate = LocalDate.of(2026, 5, 4), + leftDate = LocalDate.of(2026, 5, 4), + ) + + assertNull( + MembershipConsistencyPolicy.violation( + listOf(laterInsertedFirst, earlierInsertedSecond), + ), + ) + } + private fun interval(id: Long, joined: String?, left: String?) = MembershipInterval( id = id, joinedAt = joined?.let(Instant::parse), diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileIdentityTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileIdentityTest.kt new file mode 100644 index 0000000..976ca19 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileIdentityTest.kt @@ -0,0 +1,62 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlatoonProfileIdentityTest { + @Test + fun identityIsStableAndSeparatesPublisherRegionAndPlatoon() { + val first = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ) + + assertEquals( + first, + PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ), + ) + assertNotEquals( + first, + PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_JAPAN, + 101817L, + ), + ) + assertNotEquals( + first, + PlatoonProfileIdentity.storageId( + PlatoonClient.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + 101817L, + ), + ) + assertTrue(PlatoonProfileIdentity.isValidStorageId(first)) + } + + @Test + fun legacyScopeCannotImpersonateAClientProfile() { + assertThrows(IllegalArgumentException::class.java) { + PlatoonProfile( + storageId = PlatoonProfileIdentity.LEGACY_STORAGE_ID, + client = PlatoonClient.HAOPLAY, + serverRegion = GameServerRegion.HAOPLAY_KOREA, + platoonId = 101817L, + platoonName = "Invalid legacy identity", + emblemPrimary = emptyList(), + emblemSecondary = emptyList(), + lastSeenAt = java.time.Instant.EPOCH, + legacy = true, + ) + } + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyMemberNameProjectionTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyMemberNameProjectionTest.kt new file mode 100644 index 0000000..c2df6da --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyMemberNameProjectionTest.kt @@ -0,0 +1,18 @@ +package dev.gf2log.app.management + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WeeklyMemberNameProjectionTest { + @Test + fun capturedCustomNameOverridesReportFallback() { + val merged = WeeklyMemberNameProjection.merge( + reportNamesByUid = mapOf(1L to "Snapshot", 2L to "Report only"), + capturedNamesByUid = mapOf(1L to "Custom", 3L to "Event only"), + ) + + assertEquals("Custom", merged[1L]) + assertEquals("Report only", merged[2L]) + assertEquals("Event only", merged[3L]) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt new file mode 100644 index 0000000..2fe3146 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt @@ -0,0 +1,15 @@ +package dev.gf2log.app.management + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyNotePolicyTest { + @Test + fun acceptsOnlyCountsBelowTheHistoryBound() { + assertTrue(WeeklyNotePolicy.canAdd(0)) + assertTrue(WeeklyNotePolicy.canAdd(127)) + assertFalse(WeeklyNotePolicy.canAdd(128)) + assertFalse(WeeklyNotePolicy.canAdd(-1)) + } +} diff --git a/app/src/test/java/dev/gf2log/app/settings/ClientServerRegionPolicyTest.kt b/app/src/test/java/dev/gf2log/app/settings/ClientServerRegionPolicyTest.kt new file mode 100644 index 0000000..c439301 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/ClientServerRegionPolicyTest.kt @@ -0,0 +1,22 @@ +package dev.gf2log.app.settings + +import dev.gf2log.app.SupportedGamePackages +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClientServerRegionPolicyTest { + @Test + fun everyPublisherHasOnlyItsOwnRegions() { + val haoPlay = ClientServerRegionPreferences + .allowedFor(SupportedGamePackages.HAOPLAY) + .toSet() + val darkwinter = ClientServerRegionPreferences + .allowedFor(SupportedGamePackages.DARKWINTER) + .toSet() + + assertTrue(haoPlay.none(darkwinter::contains)) + assertTrue(haoPlay.all { it.name.startsWith("HAOPLAY_") }) + assertTrue(darkwinter.all { it.name.startsWith("DARKWINTER_") }) + assertTrue(SupportedGamePackages.HAOPLAY != SupportedGamePackages.DARKWINTER) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 904b399..9384646 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,9 +69,13 @@ Payload `21905` is the authoritative Platoon identity for the decoded flow. On Android 10 and newer, the capture service also resolves the original connection tuple to the owning supported package through Android's VPN owner API. Remote IP addresses and DNS/SNI labels are diagnostic endpoint metadata only; they are -not persistence keys. The current schema remains single-Platoon until every -database, import, CSV, backup, history, and UI path can enforce one composite -scope without fallback to an unscoped record. +not persistence keys. Android 8–9 falls back only when exactly one supported +client is installed. Management payloads are quarantined until a flow has both +a verified supported owner and valid `21905`. The composite +`(client package, selected server region, Platoon ID)` is hashed into a stable +private storage ID used by every database, import, retained CSV, checkpoint, +weekly setting, and backup path. Existing v2.3.x storage remains in place as a +legacy profile instead of being copied or destructively migrated. ## Memory and concurrency limits @@ -83,6 +87,9 @@ scope without fallback to an unscoped record. Quarantine is advanced from frame metadata before protobuf decoding, so even a malformed terminal fragment clears the discarded dataset deterministically. - The parser service has one worker and a queue capped at 256 payload chunks. +- Before identity is established, each flow retains at most 32 decoded payload + objects. Overflow rejects that flow until closure. The profile registry holds + at most 16 bounded identities. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. - Outgoing plaintext chunks are used only for native flow classification. - Flow-close callbacks finalize any pending recognized payload before removing parser state. @@ -170,7 +177,9 @@ plaintext TCP streams. Do not add pinning or anti-cheat bypasses. - `protocol` is an Android-independent decoding library. It owns framing, protobuf wire decoding, typed payload models, and text/CSV formatting. - `capture` owns the VPN/native lifecycle, bounded per-flow parsing, and - translation of completed capture batches into management-domain input. + translation of completed capture batches into management-domain input. Its + per-flow session is the only bridge from a verified composite identity to a + scoped management repository. - `management` owns evidence policy, reporting rules, the repository facade, private SQLite persistence, and the retained completed-roster directory. It does not depend on the capture package. @@ -208,6 +217,18 @@ timezone. Schema-v1 through schema-v3 backups remain accepted with safe defaults and completed onboarding defaults so an experienced restoring user is not trapped in the guide. +`PlatoonProfileRegistry` is a small registry, not a second source of member +truth. Home, Platoon, Weekly, and Settings selectors only choose the active +scope; Activities recreate and construct repositories, member ordering, +cutlines, timezone, CSV, and checkpoint helpers from that scope. One-time +capture maintains its required-payload checklist per scope so observations +from two clients cannot be combined into a false completion. + +CSV preview and apply retain the same immutable scope even if an Activity is +recreated. A scoped backup is validated before profile metadata changes; after +the database transaction commits, restore selects the archived profile and +aligns that publisher's future capture-region routing with the archived server. + The design deliberately favors composition over deep inheritance. Abstraction and polymorphism appear at real variation points (`GameData`, `ParseEvent`, and the native listener contract); encapsulation is provided by stores and the @@ -237,7 +258,10 @@ selected state without reading, validating, or replacing app settings. Format v2 adds a checksummed, strictly typed settings payload containing only user-owned configuration; capture diagnostics, raw packet history, signing material, and internal migration flags -are excluded. +are excluded. Format v3 binds either archive scope to the deterministic client, +server-region, and Platoon identity. Restoring v3 creates or replaces only that +profile and then selects it; other profile databases and evidence directories +are untouched. Older v1/v2 archives restore into the unmoved legacy profile. Complete restore validates the filename, archive entries and identity, checksums, settings completeness and ranges, current database schema, SQLite From 7d2a17ce0f3b9df787cc33a81dc0ee3fbb63170d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Mon, 24 Aug 2026 23:37:53 +0900 Subject: [PATCH 2/4] fix: quarantine rejected profile flows --- CHANGELOG.md | 3 ++ SECURITY.md | 6 ++- .../PlatoonProfileRegistryIntegrationTest.kt | 17 ++++++++ .../dev/gf2log/app/PlatoonProfileSelector.kt | 30 +++++++++++++- .../gf2log/app/capture/CaptureVpnService.kt | 41 ++++++++++++++++--- .../app/capture/PlatoonProfilePolicy.kt | 14 +++++++ .../gf2log/app/management/PlatoonProfile.kt | 30 ++++++++++++++ app/src/main/res/values-ko/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ .../app/capture/PlatoonProfilePolicyTest.kt | 14 +++++++ docs/ARCHITECTURE.md | 5 +++ 11 files changed, 160 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc7856..5d831bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ All notable changes to mobileGF2logger are documented here. - Bound the profile registry, profile metadata, and pre-identity flow buffer; reject invalid restores before metadata changes, preserve the selected import scope through preview/apply, and restore the matching client-region routing. +- Quarantine identity-changing or admission-failed flows, admit at most one new + profile per client per user-started capture, and let users forget selector + metadata without deleting the isolated Platoon data. ## 2.3.3 - 2026-08-24 diff --git a/SECURITY.md b/SECURITY.md index d84e951..f7f7191 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,7 +53,11 @@ supported client is installed; ambiguous flows remain quarantined. Payload composite of verified client, user-selected server region, and Platoon ID may select an isolated management database, retained CSV directory, checkpoint, weekly settings, or backup scope. Pre-identity payloads, registered profiles, -and profile metadata are independently bounded. +and profile metadata are independently bounded. A flow is permanently +quarantined until closure if its identity changes or profile admission fails. +Each user-started capture may admit at most one new profile per supported +client; existing profiles remain usable, and a confirmed selector action can +forget registry metadata to recover capacity without deleting isolated data. Exports and Discord sends are explicit user actions that move selected data out of Android private storage. Backups are checksummed and strictly validated but diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 6c7f785..5d5d530 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -146,6 +146,23 @@ class PlatoonProfileRegistryIntegrationTest { assertTrue(registry.removeIfInactive(profiles.last().storageId)) assertEquals(PlatoonProfileRegistry.MAX_PROFILES - 1, registry.list().size) assertFalse(registry.removeIfInactive(profiles.first().storageId)) + + val retained = File( + PlatoonStorageScope(profiles.first().storageId).rootDirectory(context), + "retained-proof.txt", + ).apply { + parentFile?.mkdirs() + writeText("preserve") + } + assertTrue(registry.forget(profiles.first().storageId)) + assertTrue(retained.isFile) + assertFalse(registry.list().any { it.storageId == profiles.first().storageId }) + val recovered = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(999u, "Recovered capacity", emptyList(), emptyList()), + ) + assertEquals(999L, recovered.platoonId) } @Test diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index ba7760f..6f10db1 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -48,8 +48,9 @@ internal object PlatoonProfileSelector { Toast.makeText(activity, R.string.no_platoon_detected_detail, Toast.LENGTH_SHORT).show() return } - val activeId = registry.active()?.storageId - AlertDialog.Builder(activity) + val active = registry.active() + val activeId = active?.storageId + val builder = AlertDialog.Builder(activity) .setTitle(R.string.select_platoon) .setSingleChoiceItems( profiles.map { label(activity, it) }.toTypedArray(), @@ -62,6 +63,31 @@ internal object PlatoonProfileSelector { } } .setNegativeButton(android.R.string.cancel, null) + if (active != null && !active.legacy) { + builder.setNeutralButton(R.string.forget_platoon_profile) { _, _ -> + confirmForget(activity, registry, active) + } + } + builder.show() + } + + private fun confirmForget( + activity: Activity, + registry: PlatoonProfileRegistry, + profile: PlatoonProfile, + ) { + AlertDialog.Builder(activity) + .setTitle(R.string.forget_platoon_profile) + .setMessage(R.string.forget_platoon_profile_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.forget) { _, _ -> + if (registry.forget(profile.storageId)) { + activity.recreate() + } else { + Toast.makeText(activity, R.string.unable_to_forget_platoon, Toast.LENGTH_SHORT) + .show() + } + } .show() } 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 c9feec2..ebff703 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -15,6 +15,8 @@ import android.os.ParcelFileDescriptor import dev.gf2log.app.R import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.management.PlatoonProfileIdentity import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope @@ -54,6 +56,7 @@ class CaptureVpnService : VpnService() { private val droppedParserTaskCount = AtomicLong() private val unknownPayloadCounts = ConcurrentHashMap() private val captureChecklist = ScopedCaptureChecklist(REQUIRED_CAPTURE_TYPES) + private val profileAdmissionGate = PlatoonProfilePolicy.AdmissionGate() private val mainHandler = Handler(Looper.getMainLooper()) private lateinit var historyStore: CaptureHistoryStore private lateinit var profileRegistry: PlatoonProfileRegistry @@ -211,6 +214,7 @@ class CaptureVpnService : VpnService() { droppedParserTaskCount.set(0) unknownPayloadCounts.clear() captureChecklist.clear() + profileAdmissionGate.clear() mainHandler.removeCallbacks(captureOnceGraceStop) sessionStartedAt = Instant.now() @@ -401,22 +405,42 @@ class CaptureVpnService : VpnService() { ) { val ownerPackage = metadata?.ownerPackage if (ownerPackage !in SupportedGamePackages.all) { - pendingFlowPayloads.reject(flowId) + quarantineFlow(flowId) CaptureStatus.update("Detected a Platoon profile, but its game client could not be verified") return } + val verifiedOwnerPackage = requireNotNull(ownerPackage) + val client = requireNotNull(PlatoonClient.fromPackage(verifiedOwnerPackage)) + val region = clientServerRegions.get(verifiedOwnerPackage) + val expectedStorageId = PlatoonProfileIdentity.storageId( + client, + region, + data.platoonId.toLong(), + ) + val current = flowSessions[flowId] + if (current != null && current.profile.storageId != expectedStorageId) { + quarantineFlow(flowId) + CaptureStatus.update("Discarded a flow whose Platoon identity changed") + return + } + val alreadyRegistered = profileRegistry.find(expectedStorageId) != null + if (!profileAdmissionGate.canAdmit(verifiedOwnerPackage, alreadyRegistered)) { + quarantineFlow(flowId) + CaptureStatus.update("Start a new capture before adding another Platoon for this client") + return + } + if (!alreadyRegistered) profileAdmissionGate.markAdmitted(verifiedOwnerPackage) val profile = runCatching { profileRegistry.upsertDetected( - ownerPackage = requireNotNull(ownerPackage), - region = clientServerRegions.get(ownerPackage), + ownerPackage = verifiedOwnerPackage, + region = region, data = data, ) }.getOrElse { - pendingFlowPayloads.reject(flowId) + quarantineFlow(flowId) CaptureStatus.update("Unable to isolate the detected Platoon") return } - val current = flowSessions[flowId] if (current?.profile?.storageId == profile.storageId) { markRequiredPayloadCaptured( profile.storageId, @@ -455,6 +479,13 @@ class CaptureVpnService : VpnService() { } } + /** Permanently blocks management routing for this flow until native closure. */ + private fun quarantineFlow(flowId: Long) { + taintedFlows += flowId + flowSessions.remove(flowId)?.close() + pendingFlowPayloads.reject(flowId) + } + private fun routePayload( session: PlatoonCaptureSession, payload: ParsedPayload, diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt index a6bdb97..4d44ff3 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt @@ -11,4 +11,18 @@ internal object PlatoonProfilePolicy { profile.platoonName.isNotBlank() && profile.platoonName.length <= MAX_NAME_LENGTH && profile.platoonName.none(Char::isISOControl) + + /** One user-started capture may persist at most one new profile per supported client. */ + internal class AdmissionGate { + private val admittedClients = mutableSetOf() + + fun canAdmit(ownerPackage: String, alreadyRegistered: Boolean): Boolean = + alreadyRegistered || ownerPackage !in admittedClients + + fun markAdmitted(ownerPackage: String) { + admittedClients += ownerPackage + } + + fun clear() = admittedClients.clear() + } } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index 5b4aef3..9da3f97 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -263,6 +263,36 @@ internal class PlatoonProfileRegistry(context: Context) { .commit() } + /** Forgets selector metadata without deleting the isolated database or retained evidence. */ + fun forget(storageId: String): Boolean = synchronized(lock) { + require(PlatoonProfileIdentity.isValidStorageId(storageId)) + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) return@synchronized false + if (readLocked(storageId) == null) return@synchronized false + val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + if (!ids.remove(storageId)) return@synchronized false + val remaining = ids.mapNotNull(::readLocked) + val prefix = "$KEY_PROFILE.$storageId." + val editor = preferences.edit() + .putStringSet(KEY_IDS, ids) + .remove(prefix + CLIENT) + .remove(prefix + REGION) + .remove(prefix + PLATOON_ID) + .remove(prefix + NAME) + .remove(prefix + EMBLEM_PRIMARY) + .remove(prefix + EMBLEM_SECONDARY) + .remove(prefix + LAST_SEEN) + .remove(prefix + LEGACY) + if (preferences.getString(KEY_ACTIVE, null) == storageId) { + val fallback = remaining.maxByOrNull(PlatoonProfile::lastSeenAt) + if (fallback == null) { + editor.remove(KEY_ACTIVE) + } else { + editor.putString(KEY_ACTIVE, fallback.storageId) + } + } + editor.commit() + } + private fun readAllLocked(): List = preferences .getStringSet(KEY_IDS, emptySet()) .orEmpty() diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b23f4c4..9679f03 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -410,6 +410,10 @@ 이 표 히스토리 항목을 더 이상 사용할 수 없습니다. 복원한 과거 주간 표를 표시하고 있습니다. 새 근거가 들어오면 최신 표로 돌아갑니다. 서클 선택 + 프로필 잊기 + 프로필 수용량을 확보하기 위해 이 서클을 선택 목록에서 제거할까요? 비공개 데이터는 유지되며, 해당 서클을 다시 감지하거나 복원하면 돌아옵니다. + 잊기 + 이 서클 프로필을 제거할 수 없습니다 감지된 서클 없음 지원 클라이언트에서 서클 프로필 패킷이 도착할 때까지 캡처하세요. 기존 서클 데이터 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0c3364..5ab883c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,6 +414,10 @@ This table history entry is no longer available. Showing a restored table history entry. New evidence will return this week to the live table. Select platoon + Forget profile + Remove this platoon from the selectors to recover profile capacity? Its private data is retained and returns if the platoon is detected or restored again. + Forget + Unable to forget this platoon profile No platoon detected Capture a supported client until the platoon profile arrives. Existing platoon data diff --git a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt index 69505c0..9aa6b48 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt @@ -16,6 +16,20 @@ class PlatoonProfilePolicyTest { assertTrue(PlatoonProfilePolicy.isValid(profile(101817u, "Owls"))) } + @Test + fun admissionGateAllowsOneNewProfilePerClientAndAlwaysAllowsKnownProfiles() { + val gate = PlatoonProfilePolicy.AdmissionGate() + + assertTrue(gate.canAdmit("haoplay", alreadyRegistered = false)) + gate.markAdmitted("haoplay") + assertFalse(gate.canAdmit("haoplay", alreadyRegistered = false)) + assertTrue(gate.canAdmit("haoplay", alreadyRegistered = true)) + assertTrue(gate.canAdmit("darkwinter", alreadyRegistered = false)) + + gate.clear() + assertTrue(gate.canAdmit("haoplay", alreadyRegistered = false)) + } + private fun profile(id: UInt, name: String) = PlatoonProfileData(id, name, emptyList(), emptyList()) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9384646..326ddb4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -90,6 +90,11 @@ legacy profile instead of being copied or destructively migrated. - Before identity is established, each flow retains at most 32 decoded payload objects. Overflow rejects that flow until closure. The profile registry holds at most 16 bounded identities. +- A bound flow cannot change its Platoon identity. Admission failure clears its + session and quarantines the flow until native closure. One user-started + capture may admit at most one new profile per supported client; users can + forget a detected profile from the selector to recover registry capacity + without deleting its isolated database or retained evidence. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. - Outgoing plaintext chunks are used only for native flow classification. - Flow-close callbacks finalize any pending recognized payload before removing parser state. From 259e0834acf366143409eab35bd24ea424015ea2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Mon, 24 Aug 2026 23:51:24 +0900 Subject: [PATCH 3/4] fix: close VPN flow metadata races --- CHANGELOG.md | 2 ++ SECURITY.md | 2 ++ .../app/capture/CaptureFlowStateCleanup.kt | 16 ++++++++++ .../gf2log/app/capture/CaptureVpnService.kt | 31 +++++++++++-------- .../capture/CaptureFlowStateCleanupTest.kt | 31 +++++++++++++++++++ docs/ARCHITECTURE.md | 4 ++- 6 files changed, 72 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d831bf..50f44fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ All notable changes to mobileGF2logger are documented here. - Quarantine identity-changing or admission-failed flows, admit at most one new profile per client per user-started capture, and let users forget selector metadata without deleting the isolated Platoon data. +- Remove metadata for non-parsed VPN flows on every close path and prevent a + delayed open callback from restoring metadata after a rejected close. ## 2.3.3 - 2026-08-24 diff --git a/SECURITY.md b/SECURITY.md index f7f7191..9436d39 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -58,6 +58,8 @@ quarantined until closure if its identity changes or profile admission fails. Each user-started capture may admit at most one new profile per supported client; existing profiles remain usable, and a confirmed selector action can forget registry metadata to recover capacity without deleting isolated data. +Closed flows discard address and owner metadata even when no parser was created; +queue-rejected closes quarantine the flow so delayed open work cannot restore it. Exports and Discord sends are explicit user actions that move selected data out of Android private storage. Backups are checksummed and strictly validated but diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt index 3e18555..1e187e6 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt @@ -2,6 +2,22 @@ package dev.gf2log.app.capture /** Removes every piece of per-flow state even when no stream parser was ever created. */ internal object CaptureFlowStateCleanup { + /** + * Publishes metadata unless an out-of-band close rejection already quarantined the flow. + * The post-write check covers both possible races: close-before-write and close-after-write. + */ + fun registerUnlessQuarantined( + flowId: Long, + value: CaptureFlowMetadata, + metadata: MutableMap, + quarantinedFlows: Set, + ): Boolean { + metadata[flowId] = value + if (flowId !in quarantinedFlows) return true + metadata.remove(flowId) + return false + } + fun remove( flowId: Long, parsers: MutableMap, 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 ebff703..1dd9c63 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -300,20 +300,25 @@ class CaptureVpnService : VpnService() { remotePort: Int, ) { submitParserTask { - flowMetadata[flowId] = CaptureFlowMetadata( - protocol = protocol, - localAddress = localAddress, - localPort = localPort, - remoteAddress = remoteAddress, - remotePort = remotePort, - ownerPackage = CaptureFlowOwnerResolver.resolve( - this, - protocol, - localAddress, - localPort, - remoteAddress, - remotePort, + CaptureFlowStateCleanup.registerUnlessQuarantined( + flowId = flowId, + value = CaptureFlowMetadata( + protocol = protocol, + localAddress = localAddress, + localPort = localPort, + remoteAddress = remoteAddress, + remotePort = remotePort, + ownerPackage = CaptureFlowOwnerResolver.resolve( + this, + protocol, + localAddress, + localPort, + remoteAddress, + remotePort, + ), ), + metadata = flowMetadata, + quarantinedFlows = taintedFlows, ) } } diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt index 0415250..5a29fd2 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt @@ -3,6 +3,7 @@ package dev.gf2log.app.capture import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class CaptureFlowStateCleanupTest { @@ -25,5 +26,35 @@ class CaptureFlowStateCleanupTest { assertFalse(metadata.containsKey(7L)) } + @Test + fun queuedOpenCannotRestoreMetadataAfterRejectedCloseQuarantinesFlow() { + val metadata = mutableMapOf() + + assertFalse( + CaptureFlowStateCleanup.registerUnlessQuarantined( + flowId = 7L, + value = metadata(), + metadata = metadata, + quarantinedFlows = setOf(7L), + ), + ) + assertFalse(metadata.containsKey(7L)) + } + + @Test + fun liveFlowMetadataIsRegisteredNormally() { + val metadata = mutableMapOf() + + assertTrue( + CaptureFlowStateCleanup.registerUnlessQuarantined( + flowId = 7L, + value = metadata(), + metadata = metadata, + quarantinedFlows = emptySet(), + ), + ) + assertTrue(metadata.containsKey(7L)) + } + private fun metadata() = CaptureFlowMetadata(6, "10.0.0.2", 1, "10.0.0.3", 2, null) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 326ddb4..aa4f2bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,7 +97,9 @@ legacy profile instead of being copied or destructively migrated. without deleting its isolated database or retained evidence. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. - Outgoing plaintext chunks are used only for native flow classification. -- Flow-close callbacks finalize any pending recognized payload before removing parser state. +- Flow-close callbacks finalize any pending recognized payload before removing parser state and + unconditionally remove metadata even when no parser existed. A rejected close callback + quarantines its flow, so an earlier queued open callback cannot recreate stale metadata. - Queue saturation is counted and surfaced in the capture status instead of being silently discarded. - Raw IP packets and application payloads are not persisted. - One parsed history entry is capped at 2 MiB. The packet-table projection From cadf4346e6af6f5a5bed3a76a9d20212bab1876b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Tue, 25 Aug 2026 11:13:54 +0900 Subject: [PATCH 4/4] feat: guard new Platoon profile admission --- CHANGELOG.md | 16 +- README.md | 6 +- README_KR.md | 4 +- SECURITY.md | 29 +- .../PlatoonBackupManagerIntegrationTest.kt | 87 +++++ .../PlatoonProfileRegistryIntegrationTest.kt | 213 ++++++++++++ .../gf2log/app/ActivePlatoonScopeBinding.kt | 13 + .../java/dev/gf2log/app/LocalizedActivity.kt | 6 + .../main/java/dev/gf2log/app/MainActivity.kt | 10 +- .../java/dev/gf2log/app/OnboardingActivity.kt | 2 + .../java/dev/gf2log/app/OptionsActivity.kt | 122 +++---- .../app/PendingPlatoonAdmissionPrompt.kt | 217 ++++++++++++ .../java/dev/gf2log/app/PlatoonActivity.kt | 10 +- .../dev/gf2log/app/PlatoonProfileSelector.kt | 322 ++++++++++++++++-- .../dev/gf2log/app/WeeklyReportActivity.kt | 15 +- .../app/capture/BoundedFlowPayloadBuffer.kt | 2 + .../gf2log/app/capture/CaptureVpnService.kt | 307 +++++++++++++++-- .../capture/PendingPlatoonAdmissionStore.kt | 184 ++++++++++ .../gf2log/app/management/BackupArchive.kt | 5 - .../management/MembershipConsistencyPolicy.kt | 21 +- .../app/management/PlatoonBackupManager.kt | 132 +++---- .../gf2log/app/management/PlatoonProfile.kt | 227 +++++++++++- .../PlatoonProfileAdministration.kt | 144 ++++++++ .../PlatoonProfileRestoreJournal.kt | 97 ++++++ .../app/management/PlatoonRepository.kt | 32 +- .../settings/ClientServerRegionPreferences.kt | 23 +- .../app/settings/GameTimeZonePreferences.kt | 24 ++ .../app/settings/WeeklyCutlinePreferences.kt | 19 ++ app/src/main/res/drawable/ic_delete.xml | 9 + app/src/main/res/values-ko/strings.xml | 28 ++ app/src/main/res/values/strings.xml | 28 ++ .../capture/BoundedFlowPayloadBufferTest.kt | 3 + .../PendingPlatoonAdmissionStoreTest.kt | 137 ++++++++ .../app/management/BackupArchiveTest.kt | 33 +- .../MembershipConsistencyPolicyTest.kt | 29 ++ .../PlatoonProfileRestoreJournalTest.kt | 37 ++ docs/ARCHITECTURE.md | 74 ++-- docs/PLATOON_MANAGEMENT.md | 11 +- 38 files changed, 2410 insertions(+), 268 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt create mode 100644 app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f44fa..486213a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to mobileGF2logger are documented here. -## 2.4.0 - 2026-08-24 +## 2.4.0 - 2026-08-25 ### Added @@ -12,6 +12,12 @@ All notable changes to mobileGF2logger are documented here. - Add independently configurable HaoPlay and Darkwinter capture-server presets, scoped database/CSV/checkpoint/report settings, and profile-aware format-v3 `.gf2backup` archives. +- Add profile-following automatic reset selection; choosing a Platoon also + aligns future capture routing with that profile's saved client and region. +- Add a bounded memory-only admission prompt for a newly detected Platoon and + require a compatible server choice before any packet or management data is saved. +- Add profile management with read-only verified clients, editable compatible + servers, and exact-name-confirmed deletion of one isolated Platoon. ### Changed @@ -21,7 +27,9 @@ All notable changes to mobileGF2logger are documented here. - Keep one-time-capture completion evidence isolated per detected Platoon so two clients cannot accidentally complete one checklist. - Preserve v2.3.x data as an unmoved legacy profile while new Platoons use - deterministic private databases and retained-evidence directories. + immutable private databases and retained-evidence directories. +- Replace the arbitrary timezone list with the six supported server presets; + an unconfigured client must be selected once before its first profile is admitted. ### Fixed @@ -37,6 +45,10 @@ All notable changes to mobileGF2logger are documented here. metadata without deleting the isolated Platoon data. - Remove metadata for non-parsed VPN flows on every close path and prevent a delayed open callback from restoring metadata after a rejected close. +- Keep backup profile registration and active selection inside the durable + restore journal, serialize timezone history rebuilds with ingestion, reject + overflowed flows before session creation, use transitive membership ordering, + and refresh every retained Activity after a profile switch. ## 2.3.3 - 2026-08-24 diff --git a/README.md b/README.md index 6949589..76a2e94 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,12 @@ never stored. - Can send a validated original CSV to an optional user-owned Discord incoming webhook after confirmation. - Stores the latest 100 parsed packets and up to 50 saved packets, with table and raw views, copy, export, selection, and deletion. - Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and profile-aware `.gf2backup` export/restore that leaves other Platoons unchanged. +- Keeps a newly detected Platoon's packets in bounded memory until the user confirms one of the verified client's compatible servers; unconfirmed data is discarded on force-stop or process death. +- Provides profile management for correcting server metadata without moving data and for deleting one isolated Platoon behind two confirmations and an exact-name check. - Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action. -- Supports English and Korean, System/Light/Dark themes, explicit Darkwinter/HaoPlay server-region reset presets converted to the phone timezone, and a persistent manual game-timezone fallback. +- Supports English and Korean, System/Light/Dark themes, and the six known + Darkwinter/HaoPlay server-region reset presets converted to the phone timezone. + Selecting a detected Platoon automatically follows that profile's saved region. - Registers the HaoPlay (`com.haoplay.game.and.exilium`) and Darkwinter (`com.Sunborn.SnqxExilium.Glo`) Android clients as separate VPN targets. - Creates UTF-8 Platoon-member CSV files with this column order: diff --git a/README_KR.md b/README_KR.md index 7faf1f3..f030273 100644 --- a/README_KR.md +++ b/README_KR.md @@ -24,8 +24,10 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. - 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 다른 서클을 변경하지 않는 프로필별 백업 및 복원을 지원합니다. +- 새 서클은 확인된 클라이언트에 맞는 서버를 사용자가 선택할 때까지 패킷을 제한된 메모리에만 보관하며, 강제 종료 또는 프로세스 종료 시 확인되지 않은 데이터를 버립니다. +- 서버 정보는 데이터를 이동하지 않고 수정할 수 있으며, 서클 삭제는 두 번의 확인과 정확한 서클 이름 입력을 거쳐 해당 격리 데이터만 제거합니다. - 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. -- 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. Darkwinter/HaoPlay 서버 지역별 초기화 시각을 기기 시간대로 환산하며, 예외 상황에는 게임 시간대를 수동으로 설정할 수 있습니다. +- 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. 알려진 Darkwinter/HaoPlay 6개 서버 지역의 초기화 시각을 기기 시간대로 환산하며, 감지된 서클을 선택하면 해당 프로필에 저장된 지역을 자동으로 따릅니다. - HaoPlay(`com.haoplay.game.and.exilium`)와 Darkwinter(`com.Sunborn.SnqxExilium.Glo`) Android 클라이언트를 별도의 VPN 대상으로 등록합니다. - 다음 열 순서의 UTF-8 서클 멤버 CSV 파일을 만듭니다. diff --git a/SECURITY.md b/SECURITY.md index 9436d39..3f858eb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,17 +49,32 @@ original connection tuple and maps that UID only to the fixed supported package IDs. Remote IP addresses and DNS/SNI labels are diagnostic hints, not trusted client or server identities. Android 8–9 falls back only when exactly one supported client is installed; ambiguous flows remain quarantined. Payload -`21905` supplies a bounded Platoon identity for its own decoded flow. Only the -composite of verified client, user-selected server region, and Platoon ID may -select an isolated management database, retained CSV directory, checkpoint, -weekly settings, or backup scope. Pre-identity payloads, registered profiles, -and profile metadata are independently bounded. A flow is permanently +`21905` supplies a bounded Platoon identity for its own decoded flow. Only a +confirmed profile composed from the verified client, user-selected compatible +server region, and Platoon ID may receive an immutable isolated management +database, retained CSV directory, checkpoint, weekly settings, or backup scope. +Pre-identity payloads, admission candidates, registered profiles, and profile +metadata are independently bounded. A flow is permanently quarantined until closure if its identity changes or profile admission fails. Each user-started capture may admit at most one new profile per supported -client; existing profiles remain usable, and a confirmed selector action can -forget registry metadata to recover capacity without deleting isolated data. +client; existing profiles remain usable. Full profile removal is an explicit +destructive workflow and cannot intentionally orphan a selectable scope. Closed flows discard address and owner metadata even when no parser was created; queue-rejected closes quarantine the flow so delayed open work cannot restore it. +The app does not turn an IP address, publisher default, or unverified hostname +into a persistent server-identity guess. A new supported-client/Platoon pair is +held only in a bounded process-memory admission queue until the user chooses one +of that client's compatible server regions. Before confirmation, decoded data +is absent from parsed-packet history, SQLite, retained CSV, and preferences; +explicit discard, overflow, force-stop, or process death removes it. Selecting +an existing profile safely restores that profile's saved capture region. + +Profile metadata, capture-region routing, active selection, SQLite state, +scoped settings, and retained CSV retirement share one durable restore journal. +A process death before the commit marker restores the previous values together. +Destructive profile deletion requires two confirmations including an exact-name +match, then uses a durable deletion queue so interrupted scoped cleanup resumes +before profiles are listed again. Exports and Discord sends are explicit user actions that move selected data out of Android private storage. Backups are checksummed and strictly validated but 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 5cb8ffc..82b355f 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -8,12 +8,15 @@ import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import dev.gf2log.app.TargetPackagePreferences +import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.WeeklyPngPendingState import dev.gf2log.app.WeeklyReportActivity import dev.gf2log.app.settings.AppBackupSettings import dev.gf2log.app.settings.AppBackupSettingsCodec import dev.gf2log.app.settings.AppSettingsStore import dev.gf2log.app.settings.BackupSettingsStore +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.WeeklyCutlines import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.PayloadCatalog @@ -543,6 +546,90 @@ class PlatoonBackupManagerIntegrationTest { } } + @Test + fun interruptedScopedRestoreRollsBackProfileSelectionAndCaptureRegion() { + val registry = PlatoonProfileRegistry(context) + val original = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + dev.gf2log.protocol.model.PlatoonProfileData( + 100u, + "Original", + emptyList(), + emptyList(), + ), + ) + val restored = registry.upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + dev.gf2log.protocol.model.PlatoonProfileData( + 200u, + "Restored", + listOf(1u), + listOf(2u), + ), + ) + assertTrue(registry.setActive(restored.storageId)) + PlatoonRepository(context, PlatoonStorageScope(restored.storageId)).ingest( + Instant.parse("2026-08-24T03:00:00Z"), + listOf( + GuildMember( + uid = THIRD_UID.toUInt(), + name = "Restored member", + level = 1u, + weeklyMerit = 0u, + totalMerit = 0u, + highScore = 0u, + totalScore = 0u, + lastLogin = 0u, + ), + ), + "restored.csv", + ) + val archive = ByteArrayOutputStream().also { output -> + PlatoonBackupManager(context).exportFull(output) + }.toByteArray() + + assertTrue(registry.setActive(original.storageId)) + assertTrue(registry.forget(restored.storageId)) + val restoredScope = PlatoonStorageScope(restored.storageId) + PlatoonRepository.withExclusiveDatabase(restoredScope) { + assertTrue(context.deleteDatabase(restoredScope.databaseName)) + } + ClientServerRegionPreferences(context).set( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_CHINA, + ) + + assertThrows(SimulatedProcessDeath::class.java) { + PlatoonBackupManager( + context = context, + settingsStore = dev.gf2log.app.settings.ScopedAppSettingsStore( + context, + original.storageId, + ), + restoreObserver = { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.PROFILE_METADATA_INSTALLED) { + throw SimulatedProcessDeath() + } + }, + storageScope = PlatoonStorageScope(original.storageId), + ).restoreFull(ByteArrayInputStream(archive)) + } + + assertEquals(restored.storageId, registry.activeScope().storageId) + assertEquals("Restored", registry.find(restored.storageId)?.platoonName) + PlatoonBackupManager.recoverInterruptedFullRestore(context) + + assertEquals(original.storageId, registry.activeScope().storageId) + assertEquals(null, registry.find(restored.storageId)) + assertEquals( + GameServerRegion.DARKWINTER_CHINA, + ClientServerRegionPreferences(context).get(SupportedGamePackages.DARKWINTER), + ) + assertFalse(context.getDatabasePath(restoredScope.databaseName).exists()) + } + private fun replaceDatabaseWithCurrentState() { replaceDatabaseForCheckpoint(CURRENT_UID, "Current member", "current-source.csv") } diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 5d5d530..ed38e6e 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -1,19 +1,26 @@ package dev.gf2log.app.management import androidx.test.core.app.ApplicationProvider +import dev.gf2log.app.ActivePlatoonScopeBinding import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.MemberOrderPreferences +import dev.gf2log.app.settings.WeeklyCutlinePreferences +import dev.gf2log.app.settings.WeeklyCutlines import dev.gf2log.protocol.model.PlatoonProfileData import dev.gf2log.protocol.model.GuildMember import java.io.File import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -91,6 +98,34 @@ class PlatoonProfileRegistryIntegrationTest { assertFalse(registry.setActive("0".repeat(32))) } + @Test + fun clientRegionRequiresARealSelectionAndBindingsDetectProfileChanges() { + val regions = ClientServerRegionPreferences(context) + assertEquals(null, regions.configured(SupportedGamePackages.HAOPLAY)) + regions.set(SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_JAPAN) + assertEquals( + GameServerRegion.HAOPLAY_JAPAN, + regions.configured(SupportedGamePackages.HAOPLAY), + ) + + val registry = PlatoonProfileRegistry(context) + val first = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_JAPAN, + PlatoonProfileData(1u, "First", emptyList(), emptyList()), + ) + val second = registry.upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + PlatoonProfileData(2u, "Second", emptyList(), emptyList()), + ) + assertTrue(registry.setActive(first.storageId)) + val binding = ActivePlatoonScopeBinding(context) + assertTrue(binding.isCurrent(context)) + assertTrue(registry.setActive(second.storageId)) + assertFalse(binding.isCurrent(context)) + } + @Test fun equalMemberUidsRemainIsolatedAcrossProfileDatabases() { val registry = PlatoonProfileRegistry(context) @@ -123,6 +158,173 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals("Darkwinter member", secondRepository.listMemberStatuses().single().name) } + @Test + fun switchingProfilesKeepsMembersReportsHistorySettingsAndFilesIndependent() { + val registry = PlatoonProfileRegistry(context) + val first = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101817u, "Owls", listOf(1u), listOf(2u)), + ) + val second = registry.upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + PlatoonProfileData(101817u, "Ravens", listOf(3u), listOf(4u)), + ) + val firstScope = PlatoonStorageScope(first.storageId) + val secondScope = PlatoonStorageScope(second.storageId) + val firstRepository = PlatoonRepository(context, firstScope) + val secondRepository = PlatoonRepository(context, secondScope) + val observedAt = Instant.parse("2026-08-24T12:00:00Z") + val periodStart = LocalDate.of(2026, 8, 23) + + firstRepository.ingest(observedAt, listOf(member(9u, "HaoPlay member")), "same.csv") + secondRepository.ingest(observedAt, listOf(member(9u, "Darkwinter member")), "same.csv") + MemberOrderPreferences(context, first.storageId).write(listOf(9L, 10L)) + MemberOrderPreferences(context, second.storageId).write(listOf(10L, 9L)) + WeeklyCutlinePreferences(context, first.storageId).write(WeeklyCutlines(dailyMerit = 90L)) + WeeklyCutlinePreferences(context, second.storageId).write(WeeklyCutlines(dailyMerit = 150L)) + val firstEvidence = File(firstScope.retainedCsvDirectory(context), "same.csv").apply { + parentFile?.mkdirs() + writeText("first") + } + val secondEvidence = File(secondScope.retainedCsvDirectory(context), "same.csv").apply { + parentFile?.mkdirs() + writeText("second") + } + + assertEquals( + "HaoPlay member", + firstRepository.buildWeeklyReport(periodStart, ZoneOffset.UTC, observedAt.plusSeconds(1)) + .members.single().name, + ) + assertEquals( + "Darkwinter member", + secondRepository.buildWeeklyReport(periodStart, ZoneOffset.UTC, observedAt.plusSeconds(1)) + .members.single().name, + ) + assertTrue(firstRepository.listWeeklyReportHistory(periodStart).isNotEmpty()) + assertTrue(secondRepository.listWeeklyReportHistory(periodStart).isNotEmpty()) + assertEquals(listOf(9L, 10L), MemberOrderPreferences(context, first.storageId).read()) + assertEquals(listOf(10L, 9L), MemberOrderPreferences(context, second.storageId).read()) + assertEquals(90L, WeeklyCutlinePreferences(context, first.storageId).read().dailyMerit) + assertEquals(150L, WeeklyCutlinePreferences(context, second.storageId).read().dailyMerit) + assertNotEquals(firstEvidence.canonicalPath, secondEvidence.canonicalPath) + assertEquals("first", firstEvidence.readText()) + assertEquals("second", secondEvidence.readText()) + + assertTrue(registry.setActive(first.storageId)) + assertEquals("HaoPlay member", PlatoonRepository(context).listMemberStatuses().single().name) + assertEquals( + GameServerRegion.HAOPLAY_KOREA, + ClientServerRegionPreferences(context).configured(SupportedGamePackages.HAOPLAY), + ) + assertTrue(registry.setActive(second.storageId)) + assertEquals("Darkwinter member", PlatoonRepository(context).listMemberStatuses().single().name) + assertEquals( + GameServerRegion.DARKWINTER_GLOBAL, + ClientServerRegionPreferences(context).configured(SupportedGamePackages.DARKWINTER), + ) + } + + @Test + fun changingServerRegionKeepsTheSameIsolatedDataScope() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101817u, "Owls", emptyList(), emptyList()), + ) + val scope = PlatoonStorageScope(profile.storageId) + val repository = PlatoonRepository(context, scope) + repository.ingest( + Instant.parse("2026-08-24T12:00:00Z"), + listOf(member(9u, "Preserved member")), + "preserved.csv", + ) + MemberOrderPreferences(context, profile.storageId).write(listOf(9L)) + assertTrue(registry.setActive(profile.storageId)) + + val updated = PlatoonProfileAdministration(context).changeServerRegion( + profile.storageId, + GameServerRegion.HAOPLAY_JAPAN, + ) + + assertEquals(profile.storageId, updated.storageId) + assertEquals(GameServerRegion.HAOPLAY_JAPAN, updated.serverRegion) + assertEquals( + "Preserved member", + PlatoonRepository(context, scope).listMemberStatuses().single().name, + ) + assertEquals(listOf(9L), MemberOrderPreferences(context, profile.storageId).read()) + assertEquals( + GameServerRegion.HAOPLAY_JAPAN, + ClientServerRegionPreferences(context).configured(SupportedGamePackages.HAOPLAY), + ) + } + + @Test + fun restoreCannotRegisterOneFullIdentityUnderTwoStorageScopes() { + val registry = PlatoonProfileRegistry(context) + val existing = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101817u, "Owls", emptyList(), emptyList()), + ) + val duplicate = existing.copy(storageId = PlatoonProfileIdentity.randomStorageId()) + + assertThrows(IllegalArgumentException::class.java) { + registry.requireRestoreCapacity(duplicate) + } + assertEquals(listOf(existing.storageId), registry.list().map(PlatoonProfile::storageId)) + } + + @Test + fun confirmedDeletionRemovesOnlyTheSelectedProfileAndItsScopedPreferences() { + val registry = PlatoonProfileRegistry(context) + val deleted = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(1u, "Delete me", emptyList(), emptyList()), + ) + val retained = registry.upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + PlatoonProfileData(2u, "Keep me", emptyList(), emptyList()), + ) + val deletedScope = PlatoonStorageScope(deleted.storageId) + val retainedScope = PlatoonStorageScope(retained.storageId) + PlatoonRepository(context, deletedScope).ingest( + Instant.parse("2026-08-24T12:00:00Z"), + listOf(member(1u, "Deleted member")), + "delete.csv", + ) + PlatoonRepository(context, retainedScope).ingest( + Instant.parse("2026-08-24T12:00:00Z"), + listOf(member(2u, "Retained member")), + "keep.csv", + ) + MemberOrderPreferences(context, deleted.storageId).write(listOf(1L)) + WeeklyCutlinePreferences(context, deleted.storageId).write( + WeeklyCutlines(dailyMerit = 90L), + ) + assertTrue(registry.setActive(deleted.storageId)) + + assertTrue(PlatoonProfileAdministration(context).deleteProfile(deleted.storageId)) + + assertEquals(null, registry.find(deleted.storageId)) + assertEquals(retained.storageId, registry.activeScope().storageId) + assertFalse(context.getDatabasePath(deletedScope.databaseName).exists()) + assertFalse(deletedScope.rootDirectory(context).exists()) + assertTrue(context.getDatabasePath(retainedScope.databaseName).exists()) + assertEquals( + "Retained member", + PlatoonRepository(context, retainedScope).listMemberStatuses().single().name, + ) + assertTrue(MemberOrderPreferences(context, deleted.storageId).read().isEmpty()) + assertEquals(null, WeeklyCutlinePreferences(context, deleted.storageId).read().dailyMerit) + } + @Test fun registryRejectsUnboundedNewProfilesAndCanRemoveInactiveMetadata() { val registry = PlatoonProfileRegistry(context) @@ -188,6 +390,8 @@ class PlatoonProfileRegistryIntegrationTest { } context.getSharedPreferences("platoon_profiles", android.content.Context.MODE_PRIVATE) .edit().clear().commit() + context.getSharedPreferences("user_settings", android.content.Context.MODE_PRIVATE) + .edit().clear().commit() File(context.filesDir, "platoons").deleteRecursively() PlatoonBackupManager(context).restoreFull(ByteArrayInputStream(archive)) @@ -263,6 +467,15 @@ class PlatoonProfileRegistryIntegrationTest { ClientServerRegionPreferences.PREFERENCES, android.content.Context.MODE_PRIVATE, ).edit().clear().commit() + listOf( + "platoon_member_order", + "platoon_weekly_cutlines", + "platoon_timezones", + "platoon_profile_deletions", + ).forEach { name -> + context.getSharedPreferences(name, android.content.Context.MODE_PRIVATE) + .edit().clear().commit() + } File(context.filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY).deleteRecursively() File(context.filesDir, "platoons").deleteRecursively() } diff --git a/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt b/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt new file mode 100644 index 0000000..ce67a56 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt @@ -0,0 +1,13 @@ +package dev.gf2log.app + +import android.content.Context +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonStorageScope + +/** Pins one Activity instance to the profile scope used to construct its repositories and views. */ +internal class ActivePlatoonScopeBinding(context: Context) { + val scope: PlatoonStorageScope = PlatoonProfileRegistry(context).activeScope() + + fun isCurrent(context: Context): Boolean = + PlatoonProfileRegistry(context).activeScope().storageId == scope.storageId +} diff --git a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt index 925b185..f9e3c4e 100644 --- a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt +++ b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt @@ -18,6 +18,8 @@ abstract class LocalizedActivity : Activity() { /** Allows a purpose-built surface to use a stable presentation theme. */ protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context) + protected open fun supportsPendingPlatoonAdmission(): Boolean = true + override fun onResume() { super.onResume() if ( @@ -25,6 +27,10 @@ abstract class LocalizedActivity : Activity() { preferredTheme(this) != attachedTheme ) { recreate() + return + } + if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { + PendingPlatoonAdmissionPrompt.showIfNeeded(this) } } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 0f0673c..63fdbb1 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -49,6 +49,7 @@ import java.time.format.DateTimeFormatter import java.util.concurrent.Executors class MainActivity : LocalizedActivity() { + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var statusText: TextView private lateinit var captureStateText: TextView private lateinit var captureStatusText: TextView @@ -81,6 +82,7 @@ class MainActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + profileBinding = ActivePlatoonScopeBinding(this) if (!OnboardingPreferences.isCompleted(this)) { startActivity(Intent(this, OnboardingActivity::class.java)) finish() @@ -98,6 +100,10 @@ class MainActivity : LocalizedActivity() { override fun onResume() { super.onResume() + if (!profileBinding.isCurrent(this)) { + recreate() + return + } if (!::captureStatusText.isInitialized) return renderCaptureStatus() refreshHistory() @@ -251,9 +257,9 @@ class MainActivity : LocalizedActivity() { typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) addView( - PlatoonProfileSelector.button(this@MainActivity, compact = true), + PlatoonProfileSelector.controls(this@MainActivity, compact = true), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, + dp(200), ViewGroup.LayoutParams.WRAP_CONTENT, ), ) diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index 1721118..ccf0aa0 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -22,6 +22,8 @@ import androidx.annotation.StringRes /** One-time, bilingual walkthrough of the app's five user-facing workflows. */ class OnboardingActivity : LocalizedActivity() { + override fun supportsPendingPlatoonAdmission(): Boolean = false + private lateinit var pageHost: FrameLayout private lateinit var stepLabel: TextView private lateinit var backButton: Button diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 1fd4f81..b818d35 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -45,6 +45,7 @@ import java.util.concurrent.Executors import dev.gf2log.protocol.PayloadCatalog class OptionsActivity : LocalizedActivity() { + private lateinit var profileBinding: ActivePlatoonScopeBinding private val mainHandler = Handler(Looper.getMainLooper()) private val fileIoExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "GF2FullBackup") @@ -52,10 +53,19 @@ class OptionsActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + profileBinding = ActivePlatoonScopeBinding(this) title = getString(R.string.payload_options) setContentView(buildContentView()) } + override fun onResume() { + super.onResume() + if (!profileBinding.isCurrent(this)) { + recreate() + return + } + } + override fun onDestroy() { fileIoExecutor.shutdownNow() super.onDestroy() @@ -116,7 +126,7 @@ class OptionsActivity : LocalizedActivity() { }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) }, matchWidth()) addView( - PlatoonProfileSelector.button(this@OptionsActivity), + PlatoonProfileSelector.controls(this@OptionsActivity), LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, @@ -179,42 +189,32 @@ class OptionsActivity : LocalizedActivity() { setTextColor(getColor(R.color.text_secondary)) setPadding(0, 0, 0, dp(6)) }, matchWidth()) - val resetRegion = GameTimeZonePreferences.region(context) + val resetScope = profileBinding.scope + val resetRegion = GameTimeZonePreferences.region(context, resetScope.storageId) addView(ModernUi.listRow( context = context, title = getString(R.string.server_region), - detail = resetSummary(resetRegion), + detail = resetSummary( + resetRegion, + GameTimeZonePreferences.isAutomatic(context, resetScope.storageId), + ), icon = R.drawable.ic_calendar, onClick = ::chooseGameServerRegion, ), matchWidth()) addView(ModernUi.listRow( context = context, title = getString(R.string.haoplay_capture_region), - detail = regionLabel(ClientServerRegionPreferences(context).get(SupportedGamePackages.HAOPLAY)), + detail = clientRegionLabel(SupportedGamePackages.HAOPLAY), icon = R.drawable.ic_group, onClick = { chooseClientServerRegion(SupportedGamePackages.HAOPLAY) }, ), matchWidth()) addView(ModernUi.listRow( context = context, title = getString(R.string.darkwinter_capture_region), - detail = regionLabel(ClientServerRegionPreferences(context).get(SupportedGamePackages.DARKWINTER)), + detail = clientRegionLabel(SupportedGamePackages.DARKWINTER), icon = R.drawable.ic_group, onClick = { chooseClientServerRegion(SupportedGamePackages.DARKWINTER) }, ), matchWidth()) - if (resetRegion == GameServerRegion.MANUAL) { - addView(ModernUi.listRow( - context = context, - title = getString(R.string.game_timezone), - detail = getString( - R.string.manual_timezone_summary, - GameTimeZonePreferences.get(context).id, - GameTimeZonePreferences.deviceZone().id, - ), - icon = R.drawable.ic_calendar, - onClick = ::chooseGameTimeZone, - ), matchWidth()) - } - addView(TextView(context).apply { text = getString(R.string.backup) textSize = 15f @@ -502,40 +502,33 @@ class OptionsActivity : LocalizedActivity() { recreate() } - private fun chooseGameTimeZone() { - val storageScope = PlatoonProfileRegistry(this).activeScope() - val zones = ZoneId.getAvailableZoneIds().sorted() - val current = GameTimeZonePreferences.get(this, storageScope.storageId).id - AlertDialog.Builder(this) - .setTitle(R.string.game_timezone) - .setSingleChoiceItems(zones.toTypedArray(), zones.indexOf(current)) { dialog, which -> - val selected = ZoneId.of(zones[which]) - if (selected == GameTimeZonePreferences.get(this, storageScope.storageId)) { - dialog.dismiss() - return@setSingleChoiceItems - } - dialog.dismiss() - updateGameTimeZone(storageScope, GameServerRegion.MANUAL, selected) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - private fun chooseGameServerRegion() { - val regions = GameServerRegion.entries - val current = GameTimeZonePreferences.region(this) + val storageScope = PlatoonProfileRegistry(this).activeScope() + val regions = GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL } + val choices = listOf(getString(R.string.server_region_auto)) + regions.map(::regionLabel) + val automatic = GameTimeZonePreferences.isAutomatic(this, storageScope.storageId) + val current = GameTimeZonePreferences.region(this, storageScope.storageId) + val checked = if (automatic) 0 else regions.indexOf(current).takeIf { it >= 0 }?.plus(1) ?: -1 AlertDialog.Builder(this) .setTitle(R.string.server_region) .setSingleChoiceItems( - regions.map(::regionLabel).toTypedArray(), - regions.indexOf(current), + choices.toTypedArray(), + checked, ) { dialog, which -> - val selected = regions[which] dialog.dismiss() - if (selected == GameServerRegion.MANUAL) { - chooseGameTimeZone() - } else if (selected != current) { - updateResetRegion(selected) + if (which == 0) { + if (storageScope.isLegacy) { + Toast.makeText( + this, + R.string.auto_region_requires_detected_platoon, + Toast.LENGTH_SHORT, + ).show() + } else if (!automatic) { + updateGameTimeZone(storageScope, null) + } + } else { + val selected = regions[which - 1] + if (automatic || selected != current) updateGameTimeZone(storageScope, selected) } } .setNegativeButton(android.R.string.cancel, null) @@ -545,7 +538,7 @@ class OptionsActivity : LocalizedActivity() { private fun chooseClientServerRegion(packageName: String) { val preferences = ClientServerRegionPreferences(this) val regions = preferences.allowed(packageName) - val current = preferences.get(packageName) + val current = preferences.configured(packageName) AlertDialog.Builder(this) .setTitle( if (packageName == SupportedGamePackages.HAOPLAY) { @@ -566,36 +559,27 @@ class OptionsActivity : LocalizedActivity() { .show() } - private fun updateResetRegion(region: GameServerRegion) { - val storageScope = PlatoonProfileRegistry(this).activeScope() - updateGameTimeZone(storageScope, region, requireNotNull(region.serverZone)) - } - private fun updateGameTimeZone( storageScope: PlatoonStorageScope, - region: GameServerRegion, - zoneId: ZoneId, + region: GameServerRegion?, ) { fileIoExecutor.execute { + val previousAutomatic = GameTimeZonePreferences.isAutomatic(this, storageScope.storageId) val previousRegion = GameTimeZonePreferences.region(this, storageScope.storageId) - val previousZone = GameTimeZonePreferences.get(this, storageScope.storageId) val result = runCatching { - if (region == GameServerRegion.MANUAL) { - GameTimeZonePreferences.set(this, zoneId, storageScope.storageId) + if (region == null) { + GameTimeZonePreferences.clearRegionOverride(this, storageScope.storageId) } else { GameTimeZonePreferences.setRegion(this, region, storageScope.storageId) } + val zoneId = GameTimeZonePreferences.get(this, storageScope.storageId) try { PlatoonRepository(this, storageScope) .rebuildWeeklyHistoryForTimeZoneChange(zoneId) } catch (error: Exception) { runCatching { - if (previousRegion == GameServerRegion.MANUAL) { - GameTimeZonePreferences.set( - this, - previousZone, - storageScope.storageId, - ) + if (previousAutomatic) { + GameTimeZonePreferences.clearRegionOverride(this, storageScope.storageId) } else { GameTimeZonePreferences.setRegion( this, @@ -620,7 +604,7 @@ class OptionsActivity : LocalizedActivity() { } } - private fun resetSummary(region: GameServerRegion): String { + private fun resetSummary(region: GameServerRegion, automatic: Boolean): String { if (region == GameServerRegion.MANUAL) { return getString( R.string.manual_reset_region_summary, @@ -631,12 +615,13 @@ class OptionsActivity : LocalizedActivity() { val localReset = region.nextReset() .atZone(GameTimeZonePreferences.deviceZone()) .format(RESET_LOCAL_TIME) - return getString( + val resolved = getString( R.string.server_reset_summary, regionLabel(region), localReset, GameTimeZonePreferences.deviceZone().id, ) + return if (automatic) getString(R.string.auto_server_reset_summary, resolved) else resolved } private fun regionLabel(region: GameServerRegion): String = getString( @@ -651,6 +636,11 @@ class OptionsActivity : LocalizedActivity() { }, ) + private fun clientRegionLabel(packageName: String): String = + ClientServerRegionPreferences(this).configured(packageName) + ?.let(::regionLabel) + ?: getString(R.string.server_region_not_configured) + private fun payloadName(payloadType: Int): String = getString( when (payloadType) { Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> R.string.payload_name_platoon_profile diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt new file mode 100644 index 0000000..7443e68 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -0,0 +1,217 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.graphics.Typeface +import android.text.TextUtils +import android.view.Gravity +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import dev.gf2log.app.capture.CaptureVpnService +import dev.gf2log.app.capture.PendingPlatoonAdmissionStore +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import java.lang.ref.WeakReference + +/** Presents the process-memory admission gate without moving capture policy into an Activity. */ +internal object PendingPlatoonAdmissionPrompt { + private var visibleDialog = WeakReference(null) + + fun showIfNeeded(activity: Activity) { + if (activity.isFinishing || activity.isDestroyed) return + if (visibleDialog.get()?.isShowing == true) return + val candidate = PendingPlatoonAdmissionStore.summaries().firstOrNull() ?: return + show(activity, candidate) + } + + private fun show( + activity: Activity, + candidate: PendingPlatoonAdmissionStore.Summary, + ) { + val client = PlatoonClient.fromPackage(candidate.ownerPackage) ?: return + val regions = ClientServerRegionPreferences.allowedFor(candidate.ownerPackage) + var selected: GameServerRegion? = null + val content = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(activity.dp(20), activity.dp(4), activity.dp(20), 0) + addView(TextView(context).apply { + text = activity.getString(R.string.new_platoon_detected_message) + textSize = 14f + setTextColor(context.getColor(R.color.text_secondary)) + }, matchWidth()) + addView(profilePanel(activity, candidate, client), matchWidth().apply { + topMargin = activity.dp(16) + bottomMargin = activity.dp(12) + }) + addView(TextView(context).apply { + text = activity.getString(R.string.choose_server_region) + textSize = 15f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, matchWidth()) + addView(RadioGroup(context).apply { + orientation = RadioGroup.VERTICAL + regions.forEach { region -> + addView(RadioButton(context).apply { + id = android.view.View.generateViewId() + text = activity.serverRegionLabel(region) + textSize = 14f + minimumHeight = activity.dp(48) + setOnCheckedChangeListener { _, checked -> + if (checked) selected = region + } + }, matchWidth()) + } + }, matchWidth().apply { topMargin = activity.dp(4) }) + addView(TextView(context).apply { + text = activity.getString(R.string.new_platoon_memory_only_notice) + textSize = 12f + setTextColor(context.getColor(R.color.text_secondary)) + setPadding(0, activity.dp(8), 0, 0) + }, matchWidth()) + } + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.new_platoon_detected) + .setView(content) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.confirm, null) + .setCancelable(false) + .create() + visibleDialog = WeakReference(dialog) + dialog.setOnShowListener { + val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + isEnabled = false + usePrimaryActionStyle() + } + val radioGroup = content.getChildAt(3) as RadioGroup + radioGroup.setOnCheckedChangeListener { _, checkedId -> + val index = (0 until radioGroup.childCount) + .indexOfFirst { radioGroup.getChildAt(it).id == checkedId } + selected = regions.getOrNull(index) + confirm.isEnabled = selected != null + } + confirm.setOnClickListener { + val region = selected ?: return@setOnClickListener + if (!PendingPlatoonAdmissionStore.contains(candidate.token)) { + dialog.dismiss() + showIfNeeded(activity) + return@setOnClickListener + } + activity.startService( + Intent(activity, CaptureVpnService::class.java) + .setAction(CaptureVpnService.ACTION_CONFIRM_PENDING_PLATOON) + .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token) + .putExtra(CaptureVpnService.EXTRA_SERVER_REGION, region.storedValue), + ) + dialog.dismiss() + refreshAfterAdmission(activity, candidate.token) + } + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + confirmDiscard(activity, dialog, candidate) + } + } + dialog.setOnDismissListener { + if (visibleDialog.get() === dialog) visibleDialog.clear() + } + dialog.show() + } + + private fun profilePanel( + activity: Activity, + candidate: PendingPlatoonAdmissionStore.Summary, + client: PlatoonClient, + ) = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + background = ModernUi.panelBackground(context) + setPadding(activity.dp(14), activity.dp(12), activity.dp(14), activity.dp(12)) + addView(TextView(context).apply { + text = candidate.profile.platoonName + textSize = 17f + typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, matchWidth()) + addView(TextView(context).apply { + text = activity.getString( + R.string.pending_platoon_identity, + client.displayName, + candidate.profile.platoonId.toLong(), + ) + textSize = 13f + setTextColor(context.getColor(R.color.text_secondary)) + setPadding(0, activity.dp(4), 0, 0) + }, matchWidth()) + } + + private fun confirmDiscard( + activity: Activity, + parent: AlertDialog, + candidate: PendingPlatoonAdmissionStore.Summary, + ) { + val warning = AlertDialog.Builder(activity) + .setTitle(R.string.discard_pending_platoon_title) + .setMessage(R.string.discard_pending_platoon_message) + .setNegativeButton(R.string.keep_choosing, null) + .setPositiveButton(R.string.discard, null) + .create() + warning.setOnShowListener { + warning.getButton(AlertDialog.BUTTON_POSITIVE).apply { + useDestructiveActionStyle() + setOnClickListener { + activity.startService( + Intent(activity, CaptureVpnService::class.java) + .setAction(CaptureVpnService.ACTION_DISCARD_PENDING_PLATOON) + .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token), + ) + warning.dismiss() + parent.dismiss() + parent.window?.decorView?.postDelayed( + { showIfNeeded(activity) }, + PROMPT_REFRESH_DELAY_MILLIS, + ) + } + } + } + warning.show() + } + + private fun refreshAfterAdmission(activity: Activity, token: String, attempt: Int = 0) { + activity.window.decorView.postDelayed( + { + if (activity.isFinishing || activity.isDestroyed) return@postDelayed + if (!PendingPlatoonAdmissionStore.contains(token)) { + activity.recreate() + } else if (attempt < MAX_REFRESH_ATTEMPTS) { + refreshAfterAdmission(activity, token, attempt + 1) + } else { + showIfNeeded(activity) + } + }, + PROMPT_REFRESH_DELAY_MILLIS, + ) + } + + private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + + private fun matchWidth() = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + + private const val PROMPT_REFRESH_DELAY_MILLIS = 250L + private const val MAX_REFRESH_ATTEMPTS = 20 +} diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 3bf62e9..ff89c62 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -29,6 +29,7 @@ import java.util.concurrent.Executors class PlatoonActivity : LocalizedActivity() { private lateinit var repository: PlatoonRepository + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var summary: TextView private lateinit var memberContainer: LinearLayout private lateinit var searchInput: EditText @@ -46,7 +47,8 @@ class PlatoonActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - repository = PlatoonRepository(this) + profileBinding = ActivePlatoonScopeBinding(this) + repository = PlatoonRepository(this, profileBinding.scope) setContentView( PrimaryNavigation.wrap( this, @@ -58,6 +60,10 @@ class PlatoonActivity : LocalizedActivity() { override fun onResume() { super.onResume() + if (!profileBinding.isCurrent(this)) { + recreate() + return + } screenResumed = true val generation = ++reconciliationGeneration reconciliationExecutor.execute { @@ -101,7 +107,7 @@ class PlatoonActivity : LocalizedActivity() { setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView( - PlatoonProfileSelector.button(this@PlatoonActivity), + PlatoonProfileSelector.controls(this@PlatoonActivity), LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 6f10db1..a928c71 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -1,17 +1,50 @@ package dev.gf2log.app -import android.app.AlertDialog import android.app.Activity +import android.app.AlertDialog +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.text.Editable +import android.text.InputType import android.text.TextUtils +import android.text.TextWatcher +import android.view.Gravity +import android.view.View +import android.view.ViewGroup import android.widget.Button +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView import android.widget.Toast +import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileAdministration import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import java.util.concurrent.Executors -/** Shared, presentation-only selector for the active isolated Platoon scope. */ +/** Shared, presentation-only selector and manager for isolated Platoon scopes. */ internal object PlatoonProfileSelector { - fun button(activity: Activity, compact: Boolean = false): Button { + fun controls(activity: Activity, compact: Boolean = false): LinearLayout = + LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView( + selectorButton(activity, compact), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + addView( + manageButton(activity), + LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { + marginStart = dp(activity, 8) + }, + ) + } + + private fun selectorButton(activity: Activity, compact: Boolean): Button { val registry = PlatoonProfileRegistry(activity) return Button(activity).apply { text = (registry.active()?.let { label(activity, it) } @@ -21,20 +54,26 @@ internal object PlatoonProfileSelector { ellipsize = TextUtils.TruncateAt.END if (compact) { textSize = 11f - minHeight = 0 - minimumHeight = 0 maxWidth = dp(activity, 210) - setPadding(dp(activity, 10), dp(activity, 4), dp(activity, 10), dp(activity, 4)) + setPadding(dp(activity, 10), 0, dp(activity, 10), 0) background = ModernUi.panelBackground(activity).apply { setStroke(dp(activity, 1), activity.getColor(R.color.outline)) } } else { useNavigationActionStyle() } - setOnClickListener { show(activity, registry) } + setOnClickListener { showSelector(activity, registry) } } } + private fun manageButton(activity: Activity) = ImageButton(activity).apply { + setImageResource(R.drawable.ic_edit) + contentDescription = activity.getString(R.string.manage_platoon_profiles) + useModernIconStyle() + setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)) + setOnClickListener { showManagement(activity, PlatoonProfileRegistry(activity)) } + } + private fun label(activity: Activity, profile: PlatoonProfile): String = if (profile.legacy) { activity.getString(R.string.existing_platoon_data) } else { @@ -42,15 +81,14 @@ internal object PlatoonProfileSelector { "${profile.platoonName} / ${profile.platoonId}" } - private fun show(activity: Activity, registry: PlatoonProfileRegistry) { + private fun showSelector(activity: Activity, registry: PlatoonProfileRegistry) { val profiles = registry.list() if (profiles.isEmpty()) { Toast.makeText(activity, R.string.no_platoon_detected_detail, Toast.LENGTH_SHORT).show() return } - val active = registry.active() - val activeId = active?.storageId - val builder = AlertDialog.Builder(activity) + val activeId = registry.active()?.storageId + AlertDialog.Builder(activity) .setTitle(R.string.select_platoon) .setSingleChoiceItems( profiles.map { label(activity, it) }.toTypedArray(), @@ -63,34 +101,259 @@ internal object PlatoonProfileSelector { } } .setNegativeButton(android.R.string.cancel, null) - if (active != null && !active.legacy) { - builder.setNeutralButton(R.string.forget_platoon_profile) { _, _ -> - confirmForget(activity, registry, active) + .show() + } + + private fun showManagement(activity: Activity, registry: PlatoonProfileRegistry) { + val profiles = registry.list() + if (profiles.isEmpty()) { + Toast.makeText(activity, R.string.no_platoon_detected_detail, Toast.LENGTH_SHORT).show() + return + } + val activeId = registry.active()?.storageId + val list = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(activity, 12), dp(activity, 4), dp(activity, 12), dp(activity, 4)) + profiles.forEachIndexed { index, profile -> + addView(profileManagementRow(activity, profile, profile.storageId == activeId)) + if (index != profiles.lastIndex) { + addView( + View(context).apply { + setBackgroundColor(context.getColor(R.color.outline)) + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(activity, 1), + ).apply { + marginStart = dp(activity, 12) + marginEnd = dp(activity, 12) + }, + ) + } } } - builder.show() + AlertDialog.Builder(activity) + .setTitle(R.string.manage_platoon_profiles) + .setMessage(R.string.manage_platoon_profiles_detail) + .setView(ScrollView(activity).apply { addView(list) }) + .setNegativeButton(android.R.string.cancel, null) + .show() } - private fun confirmForget( + private fun profileManagementRow( activity: Activity, - registry: PlatoonProfileRegistry, profile: PlatoonProfile, - ) { + active: Boolean, + ) = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(activity, 64) + setPadding(dp(activity, 10), dp(activity, 8), dp(activity, 4), dp(activity, 8)) + addView( + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(TextView(context).apply { + text = profile.platoonName + textSize = 15f + typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, matchWidth()) + addView(TextView(context).apply { + text = if (profile.legacy) { + activity.getString(R.string.existing_platoon_data) + } else { + activity.getString( + R.string.profile_management_identity, + profile.client.displayName, + regionCode(profile.serverRegion), + profile.platoonId, + ) + } + if (active) " · ${activity.getString(R.string.active_platoon)}" else "" + textSize = 12f + setTextColor(context.getColor(R.color.text_secondary)) + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, matchWidth()) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + if (!profile.legacy) { + addView(ImageButton(context).apply { + setImageResource(R.drawable.ic_edit) + contentDescription = activity.getString( + R.string.edit_platoon_server_description, + profile.platoonName, + ) + useModernIconStyle() + setOnClickListener { chooseProfileRegion(activity, profile) } + }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { + marginStart = dp(activity, 4) + }) + addView(ImageButton(context).apply { + setImageResource(R.drawable.ic_delete) + imageTintList = ColorStateList.valueOf(context.getColor(R.color.destructive_action)) + contentDescription = activity.getString( + R.string.delete_platoon_description, + profile.platoonName, + ) + background = ModernUi.panelBackground(context) + setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)) + setOnClickListener { confirmDeleteFirst(activity, profile) } + }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { + marginStart = dp(activity, 4) + }) + } + } + + private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) { + if (CaptureStatus.isRunning) { + Toast.makeText(activity, R.string.stop_capture_before_profile_change, Toast.LENGTH_LONG) + .show() + return + } + val regions = ClientServerRegionPreferences.allowedFor(profile.client.packageName) AlertDialog.Builder(activity) - .setTitle(R.string.forget_platoon_profile) - .setMessage(R.string.forget_platoon_profile_message) + .setTitle(R.string.edit_platoon_server) + .setMessage( + activity.getString( + R.string.edit_platoon_server_message, + profile.client.displayName, + profile.platoonName, + ), + ) + .setSingleChoiceItems( + regions.map { activity.serverRegionLabel(it) }.toTypedArray(), + regions.indexOf(profile.serverRegion), + ) { dialog, which -> + dialog.dismiss() + val selected = regions[which] + if (selected != profile.serverRegion) { + runMaintenance(activity, R.string.platoon_server_update_failed) { + PlatoonProfileAdministration(activity) + .changeServerRegion(profile.storageId, selected) + } + } + } .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.forget) { _, _ -> - if (registry.forget(profile.storageId)) { + .show() + } + + private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) { + if (CaptureStatus.isRunning) { + Toast.makeText(activity, R.string.stop_capture_before_profile_change, Toast.LENGTH_LONG) + .show() + return + } + val warning = AlertDialog.Builder(activity) + .setTitle(R.string.delete_platoon_profile) + .setMessage(activity.getString(R.string.delete_platoon_first_warning, profile.platoonName)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.delete, null) + .create() + warning.setOnShowListener { + warning.getButton(AlertDialog.BUTTON_POSITIVE).apply { + useDestructiveActionStyle() + setOnClickListener { + warning.dismiss() + confirmDeleteByName(activity, profile) + } + } + } + warning.show() + } + + private fun confirmDeleteByName(activity: Activity, profile: PlatoonProfile) { + val input = EditText(activity).apply { + hint = profile.platoonName + inputType = InputType.TYPE_CLASS_TEXT + maxLines = 1 + setSingleLine(true) + contentDescription = activity.getString(R.string.type_platoon_name) + } + val content = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(activity, 20), dp(activity, 4), dp(activity, 20), 0) + addView(TextView(context).apply { + text = activity.getString(R.string.delete_platoon_name_warning, profile.platoonName) + textSize = 14f + setTextColor(context.getColor(R.color.text_secondary)) + }, matchWidth()) + addView(input, matchWidth().apply { topMargin = dp(activity, 12) }) + } + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.delete_platoon_profile) + .setView(content) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.delete, null) + .create() + dialog.setOnShowListener { + val delete = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + isEnabled = false + useDestructiveActionStyle() + setOnClickListener { + if (input.text.toString() != profile.platoonName) return@setOnClickListener + dialog.dismiss() + runMaintenance(activity, R.string.platoon_delete_failed) { + check(PlatoonProfileAdministration(activity).deleteProfile(profile.storageId)) { + "Unable to delete the Platoon profile" + } + } + } + } + input.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) { + delete.isEnabled = s?.toString() == profile.platoonName + } + + override fun afterTextChanged(s: Editable?) = Unit + }) + } + dialog.show() + } + + private fun runMaintenance( + activity: Activity, + failureMessage: Int, + operation: () -> Unit, + ) { + maintenanceExecutor.execute { + val result = runCatching(operation) + activity.runOnUiThread { + if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread + if (result.isSuccess) { activity.recreate() } else { - Toast.makeText(activity, R.string.unable_to_forget_platoon, Toast.LENGTH_SHORT) - .show() + Toast.makeText(activity, failureMessage, Toast.LENGTH_LONG).show() } } - .show() + } } + private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + private fun regionCode(region: GameServerRegion): String = when (region) { GameServerRegion.MANUAL -> "Manual" GameServerRegion.DARKWINTER_GLOBAL -> "GL" @@ -101,6 +364,15 @@ internal object PlatoonProfileSelector { GameServerRegion.HAOPLAY_ASIA -> "ASIA" } + private fun matchWidth() = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + private fun dp(activity: Activity, value: Int): Int = (value * activity.resources.displayMetrics.density).toInt() + + private val maintenanceExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "GF2ProfileMaintenance") + } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 8ef585e..c8bda3c 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -109,6 +109,7 @@ internal object WeeklyPngPendingState { class WeeklyReportActivity : LocalizedActivity() { private lateinit var repository: PlatoonRepository + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var body: LinearLayout private lateinit var reportState: WeeklyReportStateHolder private var pendingCsv: String? = null @@ -121,7 +122,8 @@ class WeeklyReportActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - repository = PlatoonRepository(this) + profileBinding = ActivePlatoonScopeBinding(this) + repository = PlatoonRepository(this, profileBinding.scope) pendingPng = WeeklyPngPendingState.restore( cacheDir, savedInstanceState?.getString(STATE_PENDING_PNG_NAME), @@ -130,7 +132,10 @@ class WeeklyReportActivity : LocalizedActivity() { savedInstanceState?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } ?.getLong(STATE_REFERENCE_DAY) ?.let(LocalDate::ofEpochDay) - ?: PlatoonPeriods.gameDay(Instant.now(), GameTimeZonePreferences.get(this)), + ?: PlatoonPeriods.gameDay( + Instant.now(), + GameTimeZonePreferences.get(this, profileBinding.scope.storageId), + ), ) body = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -147,6 +152,10 @@ class WeeklyReportActivity : LocalizedActivity() { override fun onResume() { super.onResume() + if (!profileBinding.isCurrent(this)) { + recreate() + return + } reportState.onResume() requestRender(reconcileRetainedCsv = true) } @@ -334,7 +343,7 @@ class WeeklyReportActivity : LocalizedActivity() { }, LinearLayout.LayoutParams(dp(48), dp(48))) }, matchWidth()) body.addView( - PlatoonProfileSelector.button(this), + PlatoonProfileSelector.controls(this), LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, diff --git a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt index 12f1ab3..b0a2a42 100644 --- a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -23,6 +23,8 @@ internal class BoundedFlowPayloadBuffer(private val maxItemsPerFlow: Int) { fun take(flowId: Long): List = pending.remove(flowId)?.toList().orEmpty() + fun isRejected(flowId: Long): Boolean = flowId in rejected + fun reject(flowId: Long) { pending.remove(flowId) rejected += flowId 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 1dd9c63..4fecaca 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -20,9 +20,10 @@ import dev.gf2log.app.management.PlatoonProfileIdentity import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.settings.CapturePreferences import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.protocol.Gfl2StreamParser import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.PayloadCatalog @@ -45,6 +46,7 @@ class CaptureVpnService : VpnService() { private val taintedFlows = ConcurrentHashMap.newKeySet() private val flowMetadata = ConcurrentHashMap() private val flowSessions = ConcurrentHashMap() + private val pendingAdmissionByFlow = ConcurrentHashMap() private val pendingFlowPayloads = BoundedFlowPayloadBuffer( MAX_PENDING_PAYLOADS_PER_FLOW, ) @@ -107,6 +109,38 @@ class CaptureVpnService : VpnService() { CaptureStatus.beginSession(captureOnce) startCapture() } + ACTION_CONFIRM_PENDING_PLATOON -> { + val token = intent.getStringExtra(EXTRA_PENDING_TOKEN) + val region = GameServerRegion.fromStored( + intent.getStringExtra(EXTRA_SERVER_REGION), + ) + if (token.isNullOrBlank() || region == GameServerRegion.MANUAL) { + CaptureStatus.update("Unable to confirm the pending Platoon") + stopSelf(startId) + } else if (!submitParserTask { + confirmPendingPlatoon(token, region) + if (tunnel == null) stopSelf(startId) + } + ) { + CaptureStatus.update("Unable to queue the pending Platoon confirmation") + stopSelf(startId) + } + } + ACTION_DISCARD_PENDING_PLATOON -> { + val token = intent.getStringExtra(EXTRA_PENDING_TOKEN) + if (!token.isNullOrBlank()) { + if (!submitParserTask { + discardPendingPlatoon(token) + if (tunnel == null) stopSelf(startId) + } + ) { + CaptureStatus.update("Unable to queue the pending Platoon discard") + stopSelf(startId) + } + } else { + stopSelf(startId) + } + } } return Service.START_NOT_STICKY } @@ -340,6 +374,12 @@ class CaptureVpnService : VpnService() { flowEnded = true, ) } + if ( + !pendingAdmissionByFlow.containsKey(flowId) && + !flowSessions.containsKey(flowId) + ) { + pendingFlowPayloads.take(flowId).forEach(::saveHistoryOnly) + } closeFlowSession(flowId) } ) { @@ -385,16 +425,12 @@ class CaptureVpnService : VpnService() { identifyFlow(flowId, metadata, profile) } } - if (payloadHistoryPreferences.isEnabled(event.value.payloadType)) { - runCatching { historyStore.save(event.value) } - .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } - } val session = flowSessions[flowId] if (session == null) { retainPendingPayload(flowId, event.value) return@forEachIndexed } - routePayload( + routeConfirmedPayload( session, event.value, flowEnded = flowEnded && index == decoded.lastIndex, @@ -408,6 +444,11 @@ class CaptureVpnService : VpnService() { metadata: CaptureFlowMetadata?, data: PlatoonProfileData, ) { + if (pendingFlowPayloads.isRejected(flowId)) { + quarantineFlow(flowId) + CaptureStatus.update("Discarded an identified flow whose pre-identity buffer overflowed") + return + } val ownerPackage = metadata?.ownerPackage if (ownerPackage !in SupportedGamePackages.all) { quarantineFlow(flowId) @@ -416,31 +457,43 @@ class CaptureVpnService : VpnService() { } val verifiedOwnerPackage = requireNotNull(ownerPackage) val client = requireNotNull(PlatoonClient.fromPackage(verifiedOwnerPackage)) - val region = clientServerRegions.get(verifiedOwnerPackage) - val expectedStorageId = PlatoonProfileIdentity.storageId( + pendingAdmissionByFlow[flowId]?.let { token -> + val pending = PendingPlatoonAdmissionStore.summary(token) + if ( + pending?.ownerPackage == verifiedOwnerPackage && + pending.profile.platoonId == data.platoonId + ) { + return + } + discardPendingPlatoon(token) + quarantineFlow(flowId) + CaptureStatus.update("Discarded a flow whose pending Platoon identity changed") + return + } + val knownProfiles = profileRegistry.findByClientAndPlatoonId( client, - region, data.platoonId.toLong(), ) - val current = flowSessions[flowId] - if (current != null && current.profile.storageId != expectedStorageId) { - quarantineFlow(flowId) - CaptureStatus.update("Discarded a flow whose Platoon identity changed") + val configuredRegion = clientServerRegions.configured(verifiedOwnerPackage) + val known = when { + knownProfiles.size == 1 -> knownProfiles.single() + knownProfiles.size > 1 -> knownProfiles.singleOrNull { + it.serverRegion == configuredRegion + } + else -> null + } + if (known == null) { + beginPendingPlatoon(flowId, verifiedOwnerPackage, data) return } - val alreadyRegistered = profileRegistry.find(expectedStorageId) != null - if (!profileAdmissionGate.canAdmit(verifiedOwnerPackage, alreadyRegistered)) { + val current = flowSessions[flowId] + if (current != null && current.profile.storageId != known.storageId) { quarantineFlow(flowId) - CaptureStatus.update("Start a new capture before adding another Platoon for this client") + CaptureStatus.update("Discarded a flow whose Platoon identity changed") return } - if (!alreadyRegistered) profileAdmissionGate.markAdmitted(verifiedOwnerPackage) val profile = runCatching { - profileRegistry.upsertDetected( - ownerPackage = verifiedOwnerPackage, - region = region, - data = data, - ) + profileRegistry.updateObserved(known.storageId, data) }.getOrElse { quarantineFlow(flowId) CaptureStatus.update("Unable to isolate the detected Platoon") @@ -453,16 +506,9 @@ class CaptureVpnService : VpnService() { ) return } - current?.close() - val session = PlatoonCaptureSession( - context = this, - profile = profile, - onRosterCaptured = ::markRosterCaptured, - onStatus = CaptureStatus::update, - ) - flowSessions[flowId] = session + val session = replaceFlowSession(flowId, profile) pendingFlowPayloads.take(flowId).forEach { pending -> - routePayload(session, pending) + routeConfirmedPayload(session, pending) } CaptureStatus.update( "Detected ${profile.platoonName.take(40)} (${profile.platoonId}) via " + @@ -475,6 +521,25 @@ class CaptureVpnService : VpnService() { } private fun retainPendingPayload(flowId: Long, payload: ParsedPayload) { + pendingAdmissionByFlow[flowId]?.let { token -> + when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, payload)) { + PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit + PendingPlatoonAdmissionStore.OfferResult.Claimed -> + CaptureStatus.update("Waiting for the pending Platoon confirmation to finish") + PendingPlatoonAdmissionStore.OfferResult.Missing -> { + pendingAdmissionByFlow.remove(flowId, token) + quarantineFlow(flowId) + } + is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { + result.rejectedFlowIds.forEach { rejectedFlow -> + pendingAdmissionByFlow.remove(rejectedFlow, token) + quarantineFlow(rejectedFlow) + } + CaptureStatus.update("Discarded an oversized pending Platoon capture") + } + } + return + } when (pendingFlowPayloads.offer(flowId, payload)) { BoundedFlowPayloadBuffer.OfferResult.OVERFLOW -> CaptureStatus.update("Discarded an unidentified Platoon flow that exceeded its buffer") @@ -484,6 +549,154 @@ class CaptureVpnService : VpnService() { } } + private fun beginPendingPlatoon( + flowId: Long, + ownerPackage: String, + data: PlatoonProfileData, + ) { + val result = PendingPlatoonAdmissionStore.begin(ownerPackage, data, flowId) + val token = result.token + if (token == null) { + result.rejectedFlowIds.forEach(::quarantineFlow) + CaptureStatus.update("Discarded a new Platoon because the confirmation queue is full") + return + } + pendingAdmissionByFlow[flowId] = token + pendingFlowPayloads.take(flowId).forEach { pending -> + when (val offered = PendingPlatoonAdmissionStore.offer(token, flowId, pending)) { + PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit + is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { + offered.rejectedFlowIds.forEach { rejected -> + pendingAdmissionByFlow.remove(rejected, token) + quarantineFlow(rejected) + } + CaptureStatus.update("Discarded an oversized pending Platoon capture") + return + } + PendingPlatoonAdmissionStore.OfferResult.Claimed, + PendingPlatoonAdmissionStore.OfferResult.Missing, + -> { + pendingAdmissionByFlow.remove(flowId, token) + quarantineFlow(flowId) + return + } + } + } + CaptureStatus.update("New Platoon detected; return to GF2logger and choose its server") + } + + private fun confirmPendingPlatoon(token: String, region: GameServerRegion) { + val claim = PendingPlatoonAdmissionStore.claim(token) ?: return + val ownerPackage = claim.summary.ownerPackage + if (region !in clientServerRegions.allowed(ownerPackage)) { + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.update("The selected server does not belong to the verified client") + return + } + val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) + val existing = profileRegistry.findByIdentity( + client, + region, + claim.summary.profile.platoonId.toLong(), + ) + if (!profileAdmissionGate.canAdmit(ownerPackage, existing != null)) { + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.update("Start a new capture before adding another Platoon for this client") + return + } + val previousRegion = clientServerRegions.stored(ownerPackage) + val profile = runCatching { + clientServerRegions.set(ownerPackage, region) + profileRegistry.upsertDetected(ownerPackage, region, claim.summary.profile).also { + check(profileRegistry.setActive(it.storageId)) { + "Unable to select the confirmed Platoon" + } + } + }.getOrElse { error -> + runCatching { + if (previousRegion == null) { + clientServerRegions.clear(ownerPackage) + } else { + clientServerRegions.set(ownerPackage, previousRegion) + } + }.onFailure(error::addSuppressed) + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.update("Unable to create the confirmed Platoon profile") + return + } + if (existing == null) profileAdmissionGate.markAdmitted(ownerPackage) + + val sessions = mutableMapOf() + val routed = runCatching { + claim.flowIds.forEach { flowId -> + val session = replaceFlowSession(flowId, profile) + sessions[flowId] = session + if (flowId !in claim.endedFlowIds && flowMetadata.containsKey(flowId)) { + flowSessions[flowId] = session + } + } + val lastPayloadIndexByFlow = claim.payloads + .withIndex() + .associate { it.value.flowId to it.index } + claim.payloads.forEachIndexed { index, buffered -> + val session = sessions.getOrPut(buffered.flowId) { + replaceFlowSession(buffered.flowId, profile) + } + routeConfirmedPayload( + session = session, + payload = buffered.payload, + flowEnded = buffered.flowId in claim.endedFlowIds && + lastPayloadIndexByFlow[buffered.flowId] == index, + ) + } + } + if (routed.isFailure) { + sessions.forEach { (flowId, session) -> + flowSessions.remove(flowId, session) + runCatching(session::close) + } + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.update("Unable to apply the confirmed Platoon packets") + return + } + claim.endedFlowIds.forEach { flowId -> + sessions.remove(flowId)?.let { session -> + flowSessions.remove(flowId, session) + session.close() + } + } + PendingPlatoonAdmissionStore.complete(token).forEach { flowId -> + pendingAdmissionByFlow.remove(flowId, token) + } + CaptureStatus.update( + "Confirmed ${profile.platoonName.take(40)} (${profile.platoonId}) via " + + profile.client.displayName, + ) + } + + private fun discardPendingPlatoon(token: String) { + PendingPlatoonAdmissionStore.discard(token).forEach { flowId -> + pendingAdmissionByFlow.remove(flowId, token) + if (flowMetadata.containsKey(flowId) || parsers.containsKey(flowId)) { + quarantineFlow(flowId) + } + } + CaptureStatus.update("Discarded the unconfirmed Platoon packets") + } + + private fun replaceFlowSession( + flowId: Long, + profile: dev.gf2log.app.management.PlatoonProfile, + ): PlatoonCaptureSession { + flowSessions.remove(flowId)?.close() + return PlatoonCaptureSession( + context = this, + profile = profile, + onRosterCaptured = ::markRosterCaptured, + onStatus = CaptureStatus::update, + ).also { flowSessions[flowId] = it } + } + /** Permanently blocks management routing for this flow until native closure. */ private fun quarantineFlow(flowId: Long) { taintedFlows += flowId @@ -522,7 +735,31 @@ class CaptureVpnService : VpnService() { }.onFailure { CaptureStatus.update("Unable to save Platoon CSV") } } + private fun routeConfirmedPayload( + session: PlatoonCaptureSession, + payload: ParsedPayload, + flowEnded: Boolean = false, + ) { + saveHistoryOnly(payload) + routePayload(session, payload, flowEnded) + if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { + markRequiredPayloadCaptured( + session.profile.storageId, + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + ) + } + } + + private fun saveHistoryOnly(payload: ParsedPayload) { + if (!payloadHistoryPreferences.isEnabled(payload.payloadType)) return + runCatching { historyStore.save(payload) } + .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } + } + private fun closeFlowSession(flowId: Long) { + pendingAdmissionByFlow.remove(flowId)?.let { token -> + PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) + } pendingFlowPayloads.remove(flowId) flowSessions.remove(flowId)?.close() } @@ -617,6 +854,10 @@ class CaptureVpnService : VpnService() { taintedFlows.clear() flowMetadata.clear() closeAllFlowSessions() + pendingAdmissionByFlow.forEach { (flowId, token) -> + PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) + } + pendingAdmissionByFlow.clear() pendingFlowPayloads.clear() saveDiagnostics() } @@ -713,7 +954,11 @@ class CaptureVpnService : VpnService() { companion object { const val ACTION_START = "dev.gf2log.action.START" const val ACTION_STOP = "dev.gf2log.action.STOP" + const val ACTION_CONFIRM_PENDING_PLATOON = "dev.gf2log.action.CONFIRM_PENDING_PLATOON" + const val ACTION_DISCARD_PENDING_PLATOON = "dev.gf2log.action.DISCARD_PENDING_PLATOON" const val EXTRA_CAPTURE_ONCE = "capture_once" + const val EXTRA_PENDING_TOKEN = "pending_platoon_token" + const val EXTRA_SERVER_REGION = "pending_server_region" private const val NOTIFICATION_CHANNEL = "capture" private const val NOTIFICATION_ID = 1 private const val VPN_ADDRESS = "10.77.0.1" diff --git a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt new file mode 100644 index 0000000..f0cedaf --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -0,0 +1,184 @@ +package dev.gf2log.app.capture + +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.PlatoonProfileData +import java.time.Instant +import java.util.UUID + +/** + * Holds unconfirmed Platoon evidence only in process memory. + * + * Nothing in this store is serialized. A force-stop or process death therefore discards every + * candidate, which prevents an unconfirmed client/server association from becoming durable. + */ +internal object PendingPlatoonAdmissionStore { + data class Summary( + val token: String, + val ownerPackage: String, + val profile: PlatoonProfileData, + val firstObservedAt: Instant, + val payloadCount: Int, + ) + + data class BufferedPayload( + val flowId: Long, + val payload: ParsedPayload, + ) + + data class Claim( + val summary: Summary, + val payloads: List, + val flowIds: Set, + val endedFlowIds: Set, + ) + + data class BeginResult( + val token: String?, + val rejectedFlowIds: Set = emptySet(), + ) + + sealed interface OfferResult { + data object Accepted : OfferResult + data object Missing : OfferResult + data object Claimed : OfferResult + data class Overflow(val rejectedFlowIds: Set) : OfferResult + } + + private data class Candidate( + val token: String, + val ownerPackage: String, + var profile: PlatoonProfileData, + val firstObservedAt: Instant, + val payloads: MutableList = mutableListOf(), + val flowIds: MutableSet = linkedSetOf(), + val endedFlowIds: MutableSet = linkedSetOf(), + var claimed: Boolean = false, + ) + + private data class IdentityKey(val ownerPackage: String, val platoonId: UInt) + + private val candidates = linkedMapOf() + private val tokenByIdentity = mutableMapOf() + + @Synchronized + fun begin( + ownerPackage: String, + profile: PlatoonProfileData, + flowId: Long, + observedAt: Instant = Instant.now(), + ): BeginResult { + require(ownerPackage in SupportedGamePackages.all) + require(PlatoonProfilePolicy.isValid(profile)) + val key = IdentityKey(ownerPackage, profile.platoonId) + tokenByIdentity[key]?.let { token -> + val existing = candidates[token] + if (existing != null && !existing.claimed) { + existing.profile = profile + existing.flowIds += flowId + return BeginResult(token) + } + tokenByIdentity.remove(key) + } + if (candidates.size >= MAX_CANDIDATES) { + return BeginResult(token = null, rejectedFlowIds = setOf(flowId)) + } + val token = UUID.randomUUID().toString() + candidates[token] = Candidate( + token = token, + ownerPackage = ownerPackage, + profile = profile, + firstObservedAt = observedAt, + flowIds = linkedSetOf(flowId), + ) + tokenByIdentity[key] = token + return BeginResult(token) + } + + @Synchronized + fun offer(token: String, flowId: Long, payload: ParsedPayload): OfferResult { + val candidate = candidates[token] ?: return OfferResult.Missing + if (candidate.claimed) return OfferResult.Claimed + if ( + candidate.payloads.size >= MAX_PAYLOADS_PER_CANDIDATE || + candidates.values.sumOf { it.payloads.size } >= MAX_TOTAL_PAYLOADS + ) { + return OfferResult.Overflow(removeLocked(token)?.flowIds.orEmpty()) + } + candidate.flowIds += flowId + candidate.payloads += BufferedPayload(flowId, payload) + return OfferResult.Accepted + } + + @Synchronized + fun markFlowEnded(token: String, flowId: Long) { + candidates[token]?.let { candidate -> + candidate.flowIds += flowId + candidate.endedFlowIds += flowId + } + } + + @Synchronized + fun summaries(): List = candidates.values + .asSequence() + .filterNot(Candidate::claimed) + .sortedBy(Candidate::firstObservedAt) + .map { it.summary() } + .toList() + + @Synchronized + fun summary(token: String): Summary? = candidates[token] + ?.takeUnless(Candidate::claimed) + ?.summary() + + @Synchronized + fun claim(token: String): Claim? { + val candidate = candidates[token] ?: return null + if (candidate.claimed) return null + candidate.claimed = true + return Claim( + summary = candidate.summary(), + payloads = candidate.payloads.toList(), + flowIds = candidate.flowIds.toSet(), + endedFlowIds = candidate.endedFlowIds.toSet(), + ) + } + + @Synchronized + fun releaseClaim(token: String) { + candidates[token]?.claimed = false + } + + @Synchronized + fun complete(token: String): Set = removeLocked(token)?.flowIds.orEmpty() + + @Synchronized + fun discard(token: String): Set = removeLocked(token)?.flowIds.orEmpty() + + @Synchronized + fun contains(token: String): Boolean = token in candidates + + @Synchronized + internal fun clearForTests() { + candidates.clear() + tokenByIdentity.clear() + } + + private fun Candidate.summary() = Summary( + token = token, + ownerPackage = ownerPackage, + profile = profile, + firstObservedAt = firstObservedAt, + payloadCount = payloads.size, + ) + + private fun removeLocked(token: String): Candidate? { + val removed = candidates.remove(token) ?: return null + tokenByIdentity.remove(IdentityKey(removed.ownerPackage, removed.profile.platoonId), token) + return removed + } + + internal const val MAX_CANDIDATES = 4 + internal const val MAX_PAYLOADS_PER_CANDIDATE = 64 + internal const val MAX_TOTAL_PAYLOADS = 128 +} diff --git a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt index a48f74e..db85845 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -237,11 +237,6 @@ internal object BackupArchive { legacy = legacy, ) result.toProfile() - if (!legacy) { - require( - result.storageId == PlatoonProfileIdentity.storageId(client, region, id), - ) { "Backup Platoon identity does not match its storage scope" } - } return result } diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt index 1183714..9e9314e 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt @@ -2,6 +2,7 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate +import java.time.ZoneOffset /** One persisted membership interval used by the deterministic consistency audit. */ internal data class MembershipInterval( @@ -54,16 +55,18 @@ internal object MembershipConsistencyPolicy { } private fun compareStarts(first: MembershipInterval, second: MembershipInterval): Int { - val dateOrder = if (first.joinedDate != null && second.joinedDate != null) { - first.joinedDate.compareTo(second.joinedDate) - } else { - requireNotNull(first.joinedAt).compareTo(requireNotNull(second.joinedAt)) - } + val firstInstant = requireNotNull(first.joinedAt) + val secondInstant = requireNotNull(second.joinedAt) + val firstDate = first.joinedDate ?: firstInstant.atZone(ZoneOffset.UTC).toLocalDate() + val secondDate = second.joinedDate ?: secondInstant.atZone(ZoneOffset.UTC).toLocalDate() + val dateOrder = firstDate.compareTo(secondDate) if (dateOrder != 0) return dateOrder - if (first.joinedTimeKnown && second.joinedTimeKnown) { - val instantOrder = requireNotNull(first.joinedAt) - .compareTo(requireNotNull(second.joinedAt)) - if (instantOrder != 0) return instantOrder + // Keep known instants contiguous and ordered before date-only boundaries. + // This fixed tuple is transitive even when both precisions share one day. + val precisionOrder = second.joinedTimeKnown.compareTo(first.joinedTimeKnown) + if (precisionOrder != 0) return precisionOrder + if (first.joinedTimeKnown) firstInstant.compareTo(secondInstant).takeIf { it != 0 }?.let { + return it } return first.id.compareTo(second.id) } 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 19f3b0e..2713d0c 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -102,16 +102,7 @@ class PlatoonBackupManager internal constructor( } try { val target = managerFor(staged) - try { - target.manager.restoreStagedPlatoon(stagedDatabase, staged) - target.installProfileMetadata() - activateRestoredProfile(target.manager.storageScope) - } catch (error: Exception) { - runCatching(target::rollbackProfileMetadata) - .exceptionOrNull() - ?.let(error::addSuppressed) - throw error - } + target.manager.restoreStagedPlatoon(stagedDatabase, staged, target) } finally { stagedDatabase.delete() } @@ -160,16 +151,7 @@ class PlatoonBackupManager internal constructor( } try { val target = managerFor(staged) - try { - target.manager.restoreStagedComplete(stagedDatabase, staged) - target.installProfileMetadata() - activateRestoredProfile(target.manager.storageScope) - } catch (error: Exception) { - runCatching(target::rollbackProfileMetadata) - .exceptionOrNull() - ?.let(error::addSuppressed) - throw error - } + target.manager.restoreStagedComplete(stagedDatabase, staged, target) } finally { stagedDatabase.delete() } @@ -178,18 +160,20 @@ class PlatoonBackupManager internal constructor( private fun restoreStagedPlatoon( stagedDatabase: File, staged: BackupArchive.StagedArchive, + target: RestoreTarget, ) { validateSelectedBackup { BackupFormatPolicy.requirePlatoonOnly(staged.formatVersion, staged.settings != null) requireArchiveMatchesScope(staged) validateDatabase(stagedDatabase, requireCurrentSchema = false) } - replaceRestoredState(stagedDatabase, restoredSettings = null) + replaceRestoredState(stagedDatabase, restoredSettings = null, restoreTarget = target) } private fun restoreStagedComplete( stagedDatabase: File, staged: BackupArchive.StagedArchive, + target: RestoreTarget, ) { val restoredSettings = validateSelectedBackup { BackupFormatPolicy.requireComplete(staged.formatVersion, staged.settings != null) @@ -198,27 +182,29 @@ class PlatoonBackupManager internal constructor( validateDatabase(stagedDatabase, requireCurrentSchema = false) } } - replaceRestoredState(stagedDatabase, restoredSettings) + replaceRestoredState(stagedDatabase, restoredSettings, restoreTarget = target) } private fun managerFor(staged: BackupArchive.StagedArchive): RestoreTarget { val registry = PlatoonProfileRegistry(appContext) val restoredProfile = staged.profile?.toProfile() restoredProfile?.let(registry::requireRestoreCapacity) - val previousProfile = restoredProfile?.let { registry.find(it.storageId) } - val clientRegions = ClientServerRegionPreferences(appContext) - val previousCaptureRegion = restoredProfile - ?.takeUnless(PlatoonProfile::legacy) - ?.let { clientRegions.get(it.client.packageName) } val scope = restoredProfile?.storageId ?.let(::PlatoonStorageScope) ?: PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID) + val previousProfile = registry.find(scope.storageId) + val previousActiveStorageId = registry.active()?.storageId + val clientRegions = ClientServerRegionPreferences(appContext) + val previousCaptureRegion = restoredProfile + ?.takeUnless(PlatoonProfile::legacy) + ?.let { clientRegions.stored(it.client.packageName) } val manager = if (scope == storageScope) { this } else { PlatoonBackupManager( context = appContext, settingsStore = ScopedAppSettingsStore(appContext, scope.storageId), + restoreObserver = restoreObserver, storageScope = scope, ) } @@ -226,8 +212,8 @@ class PlatoonBackupManager internal constructor( manager = manager, restoredProfile = restoredProfile, previousProfile = previousProfile, + previousActiveStorageId = previousActiveStorageId, registry = registry, - clientRegions = clientRegions, previousCaptureRegion = previousCaptureRegion, ) } @@ -236,37 +222,26 @@ class PlatoonBackupManager internal constructor( val manager: PlatoonBackupManager, val restoredProfile: PlatoonProfile?, val previousProfile: PlatoonProfile?, + val previousActiveStorageId: String?, val registry: PlatoonProfileRegistry, - val clientRegions: ClientServerRegionPreferences, val previousCaptureRegion: GameServerRegion?, ) { - private var metadataInstalled = false - private var captureRegionInstalled = false + fun rollbackJournal(): PlatoonProfileRestoreJournal = PlatoonProfileRestoreJournal( + targetStorageId = manager.storageScope.storageId, + previousProfile = previousProfile, + previousActiveStorageId = previousActiveStorageId, + ownerPackage = restoredProfile + ?.takeUnless(PlatoonProfile::legacy) + ?.client + ?.packageName, + previousCaptureRegion = previousCaptureRegion, + ) - fun installProfileMetadata() { - restoredProfile ?: return - registry.upsertRestored(restoredProfile) - metadataInstalled = true - if (!restoredProfile.legacy) { - clientRegions.set(restoredProfile.client.packageName, restoredProfile.serverRegion) - captureRegionInstalled = true - } - } - - fun rollbackProfileMetadata() { - val restored = restoredProfile ?: return - if (captureRegionInstalled) { - clientRegions.set( - restored.client.packageName, - requireNotNull(previousCaptureRegion), - ) - } - if (metadataInstalled) { - if (previousProfile == null) { - registry.removeIfInactive(restored.storageId) - } else { - registry.upsertRestored(previousProfile) - } + fun installProfileMetadataAndActivate() { + val storageId = restoredProfile?.let(registry::upsertRestored)?.storageId + ?: registry.ensureLegacyProfile().storageId + check(registry.setActive(storageId)) { + "Unable to select the restored Platoon" } } } @@ -282,12 +257,6 @@ class PlatoonBackupManager internal constructor( } } - private fun activateRestoredProfile(scope: PlatoonStorageScope) { - val registry = PlatoonProfileRegistry(appContext) - if (scope.isLegacy) registry.ensureLegacyProfile() else registry.ensureInitialized() - check(registry.setActive(scope.storageId)) { "Unable to select the restored Platoon" } - } - private fun backupProfile(): PlatoonProfile { val registry = PlatoonProfileRegistry(appContext) if (storageScope.isLegacy) registry.ensureLegacyProfile() else registry.ensureInitialized() @@ -330,6 +299,7 @@ class PlatoonBackupManager internal constructor( stagedDatabase: File, restoredSettings: AppBackupSettings?, retireRetainedCsv: Boolean = true, + restoreTarget: RestoreTarget? = null, ) { val retainedCsvDirectory = File( storageScope.rootDirectory(appContext), @@ -348,6 +318,7 @@ class PlatoonBackupManager internal constructor( settingsStore.read() }, databaseExisted = databaseFile.isFile, + profileRollback = restoreTarget?.rollbackJournal(), ) replaceDatabase(stagedDatabase, preservePrevious = true) restoreObserver(RestoreCheckpoint.DATABASE_INSTALLED) @@ -362,6 +333,10 @@ class PlatoonBackupManager internal constructor( ) restoreObserver(RestoreCheckpoint.RETAINED_CSV_RETIRED) } + restoreTarget?.installProfileMetadataAndActivate() + if (restoreTarget != null) { + restoreObserver(RestoreCheckpoint.PROFILE_METADATA_INSTALLED) + } writeRestoreState(RestoreState.COMMITTED) restoreObserver(RestoreCheckpoint.COMMITTED) cleanupCommittedRestore( @@ -526,6 +501,7 @@ class PlatoonBackupManager internal constructor( private fun beginRestoreTransaction( previousSettings: AppBackupSettings?, databaseExisted: Boolean, + profileRollback: PlatoonProfileRestoreJournal?, ) { val transactionDirectory = restoreTransactionDirectory(appContext, storageScope) require(!transactionDirectory.exists()) { "A previous backup restore is still pending" } @@ -540,6 +516,12 @@ class PlatoonBackupManager internal constructor( if (!databaseExisted) { writeAtomic(restoreDatabaseWasMissingFile(appContext, storageScope), ByteArray(0)) } + if (profileRollback != null) { + writeAtomic( + restoreProfileFile(appContext, storageScope), + PlatoonProfileRestoreJournalCodec.encode(profileRollback), + ) + } writeRestoreState(RestoreState.PREPARED) } @@ -567,6 +549,7 @@ class PlatoonBackupManager internal constructor( private const val RESTORE_SETTINGS_FILE = "settings.pre_restore" private const val RESTORE_SETTINGS_ROLLBACK_FILE = "settings.rollback_required" private const val RESTORE_DATABASE_WAS_MISSING_FILE = "database.was_missing" + private const val RESTORE_PROFILE_FILE = "profile.pre_restore" private val SQLITE_HEADER = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) internal fun recoverInterruptedFullRestore( @@ -704,6 +687,27 @@ class PlatoonBackupManager internal constructor( error("Unable to recover retained CSV files") } } + val profileFile = restoreProfileFile(context, scope) + if (profileFile.isFile) { + val rollback = PlatoonProfileRestoreJournalCodec.decode(profileFile.readBytes()) + require(rollback.targetStorageId == scope.storageId) { + "Profile restore journal belongs to a different Platoon" + } + rollback.ownerPackage?.let { ownerPackage -> + val preferences = ClientServerRegionPreferences(context) + val previousRegion = rollback.previousCaptureRegion + if (previousRegion == null) { + preferences.clear(ownerPackage) + } else { + preferences.set(ownerPackage, previousRegion) + } + } + PlatoonProfileRegistry(context).restoreTargetState( + targetStorageId = rollback.targetStorageId, + previousProfile = rollback.previousProfile, + previousActiveStorageId = rollback.previousActiveStorageId, + ) + } cleanupRestoreTransaction(context, scope) } @@ -776,10 +780,12 @@ class PlatoonBackupManager internal constructor( val settingsRollback = restoreSettingsRollbackFile(context, scope) val state = restoreStateFile(context, scope) val databaseWasMissing = restoreDatabaseWasMissingFile(context, scope) + val profile = restoreProfileFile(context, scope) settings.delete() settingsRollback.delete() state.delete() databaseWasMissing.delete() + profile.delete() directory.delete() } @@ -798,6 +804,9 @@ class PlatoonBackupManager internal constructor( private fun restoreDatabaseWasMissingFile(context: Context, scope: PlatoonStorageScope) = File(restoreTransactionDirectory(context, scope), RESTORE_DATABASE_WAS_MISSING_FILE) + private fun restoreProfileFile(context: Context, scope: PlatoonStorageScope) = + File(restoreTransactionDirectory(context, scope), RESTORE_PROFILE_FILE) + private fun databaseSidecars(database: File): List = listOf("", "-wal", "-shm", "-journal").map { suffix -> File(database.path + suffix) } } @@ -806,6 +815,7 @@ class PlatoonBackupManager internal constructor( DATABASE_INSTALLED, SETTINGS_REPLACED, RETAINED_CSV_RETIRED, + PROFILE_METADATA_INSTALLED, COMMITTED, } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index 9da3f97..5885a4d 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -2,10 +2,12 @@ package dev.gf2log.app.management import android.content.Context import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.protocol.model.PlatoonProfileData import java.io.File import java.security.MessageDigest +import java.security.SecureRandom import java.time.Instant /** Publisher identity resolved from Android's original VPN flow ownership. */ @@ -54,8 +56,8 @@ internal data class PlatoonProfile( } else { require(client != PlatoonClient.LEGACY) require(serverRegion != GameServerRegion.MANUAL) + require(serverRegion in ClientServerRegionPreferences.allowedFor(client.packageName)) require(platoonId > 0L) - require(storageId == PlatoonProfileIdentity.storageId(client, serverRegion, platoonId)) } } @@ -65,7 +67,7 @@ internal data class PlatoonProfile( } } -/** Deterministically maps an authoritative composite identity to a safe storage identifier. */ +/** Creates and validates immutable private storage identifiers for isolated Platoon data. */ internal object PlatoonProfileIdentity { const val LEGACY_STORAGE_ID = "legacy" private val STORAGE_ID = Regex("(?:legacy|[0-9a-f]{32})") @@ -83,6 +85,10 @@ internal object PlatoonProfileIdentity { } fun isValidStorageId(value: String): Boolean = STORAGE_ID.matches(value) + + fun randomStorageId(): String = ByteArray(16) + .also(SecureRandom()::nextBytes) + .joinToString("") { value -> "%02x".format(value) } } /** Resolves every database and retained-evidence path from one validated profile ID. */ @@ -128,6 +134,7 @@ internal class PlatoonProfileRegistry(context: Context) { private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) fun ensureInitialized(): List = synchronized(lock) { + PlatoonProfileAdministration.recoverPending(appContext) val current = readAllLocked() if (current.isNotEmpty()) return@synchronized current val legacyDatabase = appContext.getDatabasePath(PlatoonSchema.DATABASE_NAME) @@ -173,13 +180,52 @@ internal class PlatoonProfileRegistry(context: Context) { readLocked(storageId) } + fun findByIdentity( + client: PlatoonClient, + region: GameServerRegion, + platoonId: Long, + ): PlatoonProfile? = synchronized(lock) { + ensureInitialized() + readAllLocked().firstOrNull { + !it.legacy && + it.client == client && + it.serverRegion == region && + it.platoonId == platoonId + } + } + + fun findByClientAndPlatoonId( + client: PlatoonClient, + platoonId: Long, + ): List = synchronized(lock) { + ensureInitialized() + readAllLocked().filter { + !it.legacy && it.client == client && it.platoonId == platoonId + } + } + fun activeScope(): PlatoonStorageScope = PlatoonStorageScope(active()?.storageId ?: PlatoonProfileIdentity.LEGACY_STORAGE_ID) fun setActive(storageId: String): Boolean = synchronized(lock) { require(PlatoonProfileIdentity.isValidStorageId(storageId)) - if (readLocked(storageId) == null) return@synchronized false - preferences.edit().putString(KEY_ACTIVE, storageId).commit() + val selected = readLocked(storageId) ?: return@synchronized false + if (selected.legacy) { + return@synchronized preferences.edit().putString(KEY_ACTIVE, storageId).commit() + } + val clientRegions = ClientServerRegionPreferences(appContext) + val ownerPackage = selected.client.packageName + val previousRegion = clientRegions.stored(ownerPackage) + clientRegions.set(ownerPackage, selected.serverRegion) + if (preferences.edit().putString(KEY_ACTIVE, storageId).commit()) { + return@synchronized true + } + if (previousRegion == null) { + clientRegions.clear(ownerPackage) + } else { + clientRegions.set(ownerPackage, previousRegion) + } + false } fun upsertDetected( @@ -194,7 +240,13 @@ internal class PlatoonProfileRegistry(context: Context) { require(region != GameServerRegion.MANUAL) { "A server region is required" } require(data.platoonId != 0u && data.platoonName.isNotBlank()) val platoonId = data.platoonId.toLong() - val storageId = PlatoonProfileIdentity.storageId(client, region, platoonId) + val existing = readAllLocked().firstOrNull { + !it.legacy && + it.client == client && + it.serverRegion == region && + it.platoonId == platoonId + } + val storageId = existing?.storageId ?: allocateStorageIdLocked(client, region, platoonId) require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" } @@ -218,16 +270,63 @@ internal class PlatoonProfileRegistry(context: Context) { profile } - fun upsertRestored(profile: PlatoonProfile): PlatoonProfile = synchronized(lock) { - if (!profile.legacy) { + /** Updates only mutable observed profile fields while retaining the isolated storage scope. */ + fun updateObserved( + storageId: String, + data: PlatoonProfileData, + observedAt: Instant = Instant.now(), + ): PlatoonProfile = synchronized(lock) { + require(PlatoonProfilePolicyAdapter.isValid(data)) + val current = requireNotNull(readLocked(storageId)) { "Unknown Platoon profile" } + require(!current.legacy && current.platoonId == data.platoonId.toLong()) { + "Observed Platoon identity does not match the storage scope" + } + val updated = current.copy( + platoonName = normalizeName(data.platoonName), + emblemPrimary = data.emblemPrimary + .take(PlatoonProfile.MAX_EMBLEM_PARTS) + .map(UInt::toLong), + emblemSecondary = data.emblemSecondary + .take(PlatoonProfile.MAX_EMBLEM_PARTS) + .map(UInt::toLong), + lastSeenAt = observedAt, + ) + writeLocked(updated, setActive = false) + updated + } + + /** Changes server metadata without moving or merging the profile's immutable data scope. */ + fun updateServerRegion(storageId: String, region: GameServerRegion): PlatoonProfile = + synchronized(lock) { + val current = requireNotNull(readLocked(storageId)) { "Unknown Platoon profile" } + require(!current.legacy) { "Legacy data has no verified client/server identity" } + require(region in ClientServerRegionPreferences.allowedFor(current.client.packageName)) { + "The server region does not belong to this client" + } require( - profile.storageId == PlatoonProfileIdentity.storageId( - profile.client, - profile.serverRegion, - profile.platoonId, - ), - ) { "Restored Platoon identity is inconsistent" } + readAllLocked().none { + it.storageId != storageId && + !it.legacy && + it.client == current.client && + it.serverRegion == region && + it.platoonId == current.platoonId + }, + ) { "That client/server Platoon profile already exists" } + val updated = current.copy(serverRegion = region) + writeLocked(updated, setActive = false) + if (preferences.getString(KEY_ACTIVE, null) == storageId) { + runCatching { + ClientServerRegionPreferences(appContext).set(current.client.packageName, region) + }.getOrElse { error -> + writeLocked(current, setActive = false) + throw error + } + } + updated } + + fun upsertRestored(profile: PlatoonProfile): PlatoonProfile = synchronized(lock) { + requireCompatibleRestoreTargetLocked(profile) require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" } @@ -237,6 +336,7 @@ internal class PlatoonProfileRegistry(context: Context) { /** Rejects a new restore scope before any database or filesystem state is replaced. */ fun requireRestoreCapacity(profile: PlatoonProfile) = synchronized(lock) { + requireCompatibleRestoreTargetLocked(profile) require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" } @@ -263,6 +363,51 @@ internal class PlatoonProfileRegistry(context: Context) { .commit() } + /** Atomically restores only the registry entry and active pointer touched by a failed restore. */ + fun restoreTargetState( + targetStorageId: String, + previousProfile: PlatoonProfile?, + previousActiveStorageId: String?, + ) = synchronized(lock) { + require(PlatoonProfileIdentity.isValidStorageId(targetStorageId)) + require(previousProfile == null || previousProfile.storageId == targetStorageId) + require( + previousActiveStorageId == null || + PlatoonProfileIdentity.isValidStorageId(previousActiveStorageId), + ) + val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + val prefix = "$KEY_PROFILE.$targetStorageId." + val editor = preferences.edit() + if (previousProfile == null) { + ids.remove(targetStorageId) + editor.remove(prefix + CLIENT) + .remove(prefix + REGION) + .remove(prefix + PLATOON_ID) + .remove(prefix + NAME) + .remove(prefix + EMBLEM_PRIMARY) + .remove(prefix + EMBLEM_SECONDARY) + .remove(prefix + LAST_SEEN) + .remove(prefix + LEGACY) + } else { + ids += targetStorageId + editor.putString(prefix + CLIENT, previousProfile.client.name) + .putString(prefix + REGION, previousProfile.serverRegion.storedValue) + .putLong(prefix + PLATOON_ID, previousProfile.platoonId) + .putString(prefix + NAME, previousProfile.platoonName) + .putString(prefix + EMBLEM_PRIMARY, previousProfile.emblemPrimary.joinToString(",")) + .putString(prefix + EMBLEM_SECONDARY, previousProfile.emblemSecondary.joinToString(",")) + .putLong(prefix + LAST_SEEN, previousProfile.lastSeenAt.toEpochMilli()) + .putBoolean(prefix + LEGACY, previousProfile.legacy) + } + editor.putStringSet(KEY_IDS, ids) + if (previousActiveStorageId != null && previousActiveStorageId in ids) { + editor.putString(KEY_ACTIVE, previousActiveStorageId) + } else { + editor.remove(KEY_ACTIVE) + } + check(editor.commit()) { "Unable to restore the Platoon profile registry" } + } + /** Forgets selector metadata without deleting the isolated database or retained evidence. */ fun forget(storageId: String): Boolean = synchronized(lock) { require(PlatoonProfileIdentity.isValidStorageId(storageId)) @@ -332,6 +477,53 @@ internal class PlatoonProfileRegistry(context: Context) { check(editor.commit()) { "Unable to persist the Platoon profile registry" } } + private fun requireCompatibleRestoreTargetLocked(profile: PlatoonProfile) { + val profiles = readAllLocked() + profiles.firstOrNull { it.storageId == profile.storageId }?.let { existing -> + require( + existing.legacy == profile.legacy && + ( + existing.legacy || + ( + existing.client == profile.client && + existing.platoonId == profile.platoonId + ) + ), + ) { "Backup storage scope belongs to a different Platoon identity" } + } + require( + profiles.none { + it.storageId != profile.storageId && + !it.legacy && + !profile.legacy && + it.client == profile.client && + it.serverRegion == profile.serverRegion && + it.platoonId == profile.platoonId + }, + ) { "Backup Platoon identity already belongs to another storage scope" } + } + + private fun allocateStorageIdLocked( + client: PlatoonClient, + region: GameServerRegion, + platoonId: Long, + ): String { + val preferred = PlatoonProfileIdentity.storageId(client, region, platoonId) + if (readLocked(preferred) == null) return preferred + repeat(32) { + val random = PlatoonProfileIdentity.randomStorageId() + if (readLocked(random) == null) return random + } + error("Unable to allocate an isolated Platoon storage scope") + } + + private fun normalizeName(value: String): String = value + .replace(Regex("\\s+"), " ") + .filterNot(Char::isISOControl) + .trim() + .take(PlatoonProfile.MAX_NAME_LENGTH) + .also { require(it.isNotBlank()) { "Platoon name is empty after normalization" } } + private fun parseLongList(value: String?): List = value.orEmpty() .split(',') .filter(String::isNotBlank) @@ -368,3 +560,12 @@ internal class PlatoonProfileRegistry(context: Context) { private val lock = Any() } } + +/** Keeps management independent from the capture package's validation helper. */ +private object PlatoonProfilePolicyAdapter { + fun isValid(profile: PlatoonProfileData): Boolean = + profile.platoonId != 0u && + profile.platoonName.isNotBlank() && + profile.platoonName.length <= PlatoonProfile.MAX_NAME_LENGTH && + profile.platoonName.none(Char::isISOControl) +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt new file mode 100644 index 0000000..e716e6a --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt @@ -0,0 +1,144 @@ +package dev.gf2log.app.management + +import android.content.Context +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.MemberOrderPreferences +import dev.gf2log.app.settings.WeeklyCutlinePreferences + +/** Owns destructive and derived-data maintenance for isolated Platoon profiles. */ +internal class PlatoonProfileAdministration(context: Context) { + private val appContext = context.applicationContext + private val registry = PlatoonProfileRegistry(appContext) + + /** + * Updates verified server metadata while retaining the profile's immutable storage scope. + * Weekly history is rebuilt with the new reset zone and rolled back if rebuilding fails. + */ + fun changeServerRegion(storageId: String, region: GameServerRegion): PlatoonProfile { + val before = requireNotNull(registry.find(storageId)) { "Unknown Platoon profile" } + require(!before.legacy) + if (before.serverRegion == region) return before + val wasAutomatic = GameTimeZonePreferences.isAutomatic(appContext, storageId) + val previousOverride = GameTimeZonePreferences.region(appContext, storageId) + val previousZone = GameTimeZonePreferences.get(appContext, storageId) + val updated = registry.updateServerRegion(storageId, region) + return try { + GameTimeZonePreferences.clearRegionOverride(appContext, storageId) + PlatoonRepository(appContext, PlatoonStorageScope(storageId)) + .rebuildWeeklyHistoryForTimeZoneChange(requireNotNull(region.serverZone)) + updated + } catch (error: Exception) { + runCatching { registry.updateServerRegion(storageId, before.serverRegion) } + .onFailure(error::addSuppressed) + runCatching { + if (wasAutomatic) { + GameTimeZonePreferences.clearRegionOverride(appContext, storageId) + } else { + GameTimeZonePreferences.setRegion(appContext, previousOverride, storageId) + } + PlatoonRepository(appContext, PlatoonStorageScope(storageId)) + .rebuildWeeklyHistoryForTimeZoneChange(previousZone) + }.onFailure(error::addSuppressed) + throw error + } + } + + /** + * Removes selector metadata first, then finishes bounded scoped deletion from a durable queue. + * A process interruption can leave inaccessible staged data, but the next registry open resumes + * its deletion before profiles are shown. + */ + fun deleteProfile(storageId: String): Boolean { + val profile = registry.find(storageId) ?: return false + require(!profile.legacy) { "Legacy data cannot be deleted as a detected profile" } + enqueue(appContext, storageId) + if (!registry.forget(storageId)) { + dequeue(appContext, storageId) + return false + } + val removed = finishPending(appContext, storageId) + synchronizeCaptureRegion(profile.client, registry) + return removed + } + + private fun synchronizeCaptureRegion( + deletedClient: PlatoonClient, + registry: PlatoonProfileRegistry, + ) { + val remaining = registry.list() + val preferences = ClientServerRegionPreferences(appContext) + if (remaining.none { !it.legacy && it.client == deletedClient }) { + runCatching { preferences.clear(deletedClient.packageName) } + } + registry.active()?.takeUnless(PlatoonProfile::legacy)?.let { active -> + runCatching { registry.setActive(active.storageId) } + } + } + + companion object { + private const val PREFERENCES = "platoon_profile_deletions" + private const val KEY_PENDING = "pending_storage_ids" + + fun recoverPending(context: Context) { + val appContext = context.applicationContext + pending(appContext).forEach { storageId -> + if (PlatoonProfileIdentity.isValidStorageId(storageId) && + storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID + ) { + finishPending(appContext, storageId) + } else { + dequeue(appContext, storageId) + } + } + } + + private fun finishPending(context: Context, storageId: String): Boolean { + val scope = PlatoonStorageScope(storageId) + return runCatching { + PlatoonRepository.withExclusiveDatabase(scope) { + val database = context.getDatabasePath(scope.databaseName) + if (database.exists()) { + check(context.deleteDatabase(scope.databaseName)) { + "Unable to delete the Platoon database" + } + } + val root = scope.rootDirectory(context).canonicalFile + val profilesRoot = java.io.File(context.filesDir, "platoons").canonicalFile + check(root.parentFile == profilesRoot) { + "Refusing to delete outside the isolated Platoon directory" + } + if (root.exists()) { + check(root.deleteRecursively()) { "Unable to delete Platoon evidence" } + } + } + MemberOrderPreferences(context, storageId).clear() + WeeklyCutlinePreferences(context, storageId).clear() + GameTimeZonePreferences.clearScope(context, storageId) + dequeue(context, storageId) + true + }.getOrDefault(false) + } + + private fun enqueue(context: Context, storageId: String) { + val values = pending(context).toMutableSet().apply { add(storageId) } + check(preferences(context).edit().putStringSet(KEY_PENDING, values).commit()) { + "Unable to queue Platoon deletion" + } + } + + private fun dequeue(context: Context, storageId: String) { + val values = pending(context).toMutableSet().apply { remove(storageId) } + check(preferences(context).edit().putStringSet(KEY_PENDING, values).commit()) { + "Unable to finish Platoon deletion" + } + } + + private fun pending(context: Context): Set = + preferences(context).getStringSet(KEY_PENDING, emptySet()).orEmpty().toSet() + + private fun preferences(context: Context) = context.applicationContext + .getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt new file mode 100644 index 0000000..630fd0a --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt @@ -0,0 +1,97 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.time.Instant + +/** Durable rollback state for profile metadata changed by a scoped backup restore. */ +internal data class PlatoonProfileRestoreJournal( + val targetStorageId: String, + val previousProfile: PlatoonProfile?, + val previousActiveStorageId: String?, + val ownerPackage: String?, + val previousCaptureRegion: GameServerRegion?, +) + +internal object PlatoonProfileRestoreJournalCodec { + fun encode(value: PlatoonProfileRestoreJournal): ByteArray = ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + output.writeInt(VERSION) + output.writeUTF(value.targetStorageId) + output.writeNullable(value.previousActiveStorageId) + output.writeNullable(value.ownerPackage) + output.writeNullable(value.previousCaptureRegion?.storedValue) + output.writeBoolean(value.previousProfile != null) + value.previousProfile?.let { profile -> + output.writeUTF(profile.storageId) + output.writeUTF(profile.client.name) + output.writeUTF(profile.serverRegion.storedValue) + output.writeLong(profile.platoonId) + output.writeUTF(profile.platoonName) + output.writeLongList(profile.emblemPrimary) + output.writeLongList(profile.emblemSecondary) + output.writeLong(profile.lastSeenAt.toEpochMilli()) + output.writeBoolean(profile.legacy) + } + } + bytes.toByteArray() + } + + fun decode(bytes: ByteArray): PlatoonProfileRestoreJournal = + DataInputStream(ByteArrayInputStream(bytes)).use { input -> + require(input.readInt() == VERSION) { "Unsupported profile restore journal" } + val targetStorageId = input.readUTF() + require(PlatoonProfileIdentity.isValidStorageId(targetStorageId)) + val previousActive = input.readNullable()?.also { + require(PlatoonProfileIdentity.isValidStorageId(it)) + } + val ownerPackage = input.readNullable() + val previousRegion = input.readNullable()?.let(GameServerRegion::fromStored) + val previousProfile = if (input.readBoolean()) { + PlatoonProfile( + storageId = input.readUTF(), + client = PlatoonClient.valueOf(input.readUTF()), + serverRegion = GameServerRegion.fromStored(input.readUTF()), + platoonId = input.readLong(), + platoonName = input.readUTF(), + emblemPrimary = input.readLongList(), + emblemSecondary = input.readLongList(), + lastSeenAt = Instant.ofEpochMilli(input.readLong()), + legacy = input.readBoolean(), + ) + } else { + null + } + require(input.read() == -1) { "Profile restore journal contains trailing data" } + PlatoonProfileRestoreJournal( + targetStorageId, + previousProfile, + previousActive, + ownerPackage, + previousRegion, + ) + } + + private fun DataOutputStream.writeNullable(value: String?) { + writeBoolean(value != null) + if (value != null) writeUTF(value) + } + + private fun DataInputStream.readNullable(): String? = if (readBoolean()) readUTF() else null + + private fun DataOutputStream.writeLongList(values: List) { + writeInt(values.size) + values.forEach(::writeLong) + } + + private fun DataInputStream.readLongList(): List { + val size = readInt() + require(size in 0..PlatoonProfile.MAX_EMBLEM_PARTS) + return List(size) { readLong() } + } + + private const val VERSION = 1 +} 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 f6ffd31..187933b 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -390,21 +390,23 @@ internal class PlatoonRepository( access { it.clearActiveWeeklyReportHistory(periodStart.toEpochDay()) } fun rebuildWeeklyHistoryForTimeZoneChange(zoneId: ZoneId) { - val recordedAt = Instant.now() - val replacements = WeeklyReportRange - .periodStarts(access { it.listWeeklyEvidenceDays(zoneId) }) - .map { periodStart -> - val encoded = WeeklyReportHistoryCodec.encode( - buildLiveWeeklyRevision(periodStart, zoneId, recordedAt), - ) - WeeklyReportHistoryReplacement( - periodStartEpochDay = periodStart.toEpochDay(), - recordedAt = recordedAt, - fingerprint = encoded.fingerprint, - payload = encoded.payload, - ) - } - access { it.replaceWeeklyReportHistory(replacements) } + withExclusiveDatabase(storageScope) { + val recordedAt = Instant.now() + val replacements = WeeklyReportRange + .periodStarts(access { it.listWeeklyEvidenceDays(zoneId) }) + .map { periodStart -> + val encoded = WeeklyReportHistoryCodec.encode( + buildLiveWeeklyRevision(periodStart, zoneId, recordedAt), + ) + WeeklyReportHistoryReplacement( + periodStartEpochDay = periodStart.toEpochDay(), + recordedAt = recordedAt, + fingerprint = encoded.fingerprint, + payload = encoded.payload, + ) + } + access { it.replaceWeeklyReportHistory(replacements) } + } } private fun recordChangedWeeks(instants: Iterable) = diff --git a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt index 0e2cd18..0eb6d39 100644 --- a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt @@ -10,11 +10,15 @@ internal class ClientServerRegionPreferences(context: Context) { fun get(packageName: String): GameServerRegion { require(packageName in SupportedGamePackages.all) - val stored = GameServerRegion.fromStored(preferences.getString(packageName, null)) - if (stored in allowed(packageName)) return stored + return configured(packageName) ?: default(packageName) + } + /** Returns only a persisted or migrated operator choice, never a guessed default. */ + fun configured(packageName: String): GameServerRegion? { + require(packageName in SupportedGamePackages.all) + stored(packageName)?.let { return it } val legacy = GameTimeZonePreferences.legacyRegion(appContext) - return legacy.takeIf { it in allowed(packageName) } ?: default(packageName) + return legacy.takeIf { it in allowed(packageName) } } fun set(packageName: String, region: GameServerRegion) { @@ -24,6 +28,19 @@ internal class ClientServerRegionPreferences(context: Context) { } } + fun stored(packageName: String): GameServerRegion? { + require(packageName in SupportedGamePackages.all) + val value = preferences.getString(packageName, null) ?: return null + return GameServerRegion.fromStored(value).takeIf { it in allowed(packageName) } + } + + fun clear(packageName: String) { + require(packageName in SupportedGamePackages.all) + check(preferences.edit().remove(packageName).commit()) { + "Unable to clear the client server region" + } + } + fun allowed(packageName: String): List = allowedFor(packageName) private fun default(packageName: String): GameServerRegion = when (packageName) { diff --git a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt index 540c13c..3dc5389 100644 --- a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt @@ -30,6 +30,30 @@ internal object GameTimeZonePreferences { return PlatoonProfileRegistry(context).find(storageId)?.serverRegion ?: GameServerRegion.MANUAL } + fun isAutomatic(context: Context, storageId: String): Boolean = + storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID && + !scoped(context).contains(regionKey(storageId)) + + fun clearRegionOverride(context: Context, storageId: String) { + require(storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID) + check( + scoped(context).edit() + .remove(regionKey(storageId)) + .remove(zoneKey(storageId)) + .commit(), + ) { "Unable to restore automatic server selection" } + } + + fun clearScope(context: Context, storageId: String) { + require(storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID) + check( + scoped(context).edit() + .remove(regionKey(storageId)) + .remove(zoneKey(storageId)) + .commit(), + ) { "Unable to clear Platoon timezone settings" } + } + fun setRegion( context: Context, region: GameServerRegion, diff --git a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt index b7ff06b..97bd262 100644 --- a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt @@ -78,6 +78,25 @@ class WeeklyCutlinePreferences( check(editor.commit()) { "Unable to persist weekly cutlines" } } + fun clear() { + if (isLegacy) { + write(WeeklyCutlines()) + return + } + val editor = scoped.edit() + listOf( + DAILY_MERIT, + DAILY_SCORE, + DAILY_ATTEMPTS, + WEEKLY_MERIT, + WEEKLY_SCORE, + WEEKLY_ATTEMPTS, + WEEKLY_LOGIN, + WEEKLY_PATROL, + ).forEach { editor.remove(key(it)) } + check(editor.commit()) { "Unable to clear weekly cutlines" } + } + private val isLegacy: Boolean get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..db463ee --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9679f03..1c1b588 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -201,6 +201,10 @@ 하루 초기화 시간 게임 서버 지역을 선택하면 서버의 05:00 초기화 시각을 이 기기의 시간대로 환산합니다. 서버 지역 + 자동 선택 + 선택되지 않음 — 첫 캡처 전에 선택하세요 + 자동 · %1$s + 서클 프로필이 감지된 뒤 자동 선택을 사용할 수 있습니다. 시간대 수동 설정 Darkwinter 글로벌 Darkwinter 중국 @@ -208,6 +212,30 @@ HaoPlay 일본 HaoPlay 한국 HaoPlay 아시아 + 새로운 서클 감지 + 게임 클라이언트는 확인했지만 패킷만으로 서버를 확정할 수 없습니다. 패킷을 저장하기 전에 서버를 선택하세요. + 이 서클의 서버 선택 + 확인 전까지 파싱된 패킷은 메모리에만 보관됩니다. GF2logger를 강제 종료하면 삭제됩니다. + %1$s 클라이언트 · 서클 ID %2$d + 버리기 + 계속 선택 + 수집한 패킷을 버리시겠습니까? + 서버 선택을 취소하면 수집한 패킷이 저장되지 않을 수 있습니다. 확인되지 않은 서클의 패킷을 버리시겠습니까? + 서클 관리 + 감지된 서클의 서버를 수정하거나 격리된 데이터를 완전히 삭제합니다. + %1$s · %2$s · ID %3$d + 선택됨 + 클라이언트/서버 수정 + %1$s 서버 수정 + 확인된 클라이언트: %1$s (수정 불가)\n%2$s 서클의 서버를 선택하세요. + 서클을 수정하거나 삭제하기 전에 캡처를 중지하세요. + 서클 서버를 수정할 수 없습니다. 데이터는 이동되지 않았습니다. + 서클 삭제 + %1$s 삭제 + %1$s 서클을 삭제하면 멤버, 주간 표, 설정, 체크포인트 및 보관된 근거 자료가 영구적으로 제거됩니다. 복구할 수 없습니다. + 삭제하려면 서클 이름을 정확하게 입력하세요: %1$s + 정확한 서클 이름 입력 + 서클 삭제를 완료할 수 없습니다. 안전하게 다시 정리합니다. %1$s · 이 기기의 다음 초기화 %2$s (%3$s) 수동 · %1$s (기기: %2$s) 게임: %1$s · 기기: %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ab883c..ddbcfa9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,6 +201,10 @@ Daily reset time Choose the game server region. The next 05:00 server reset is converted to this phone’s timezone. Server region + Auto select + Not selected — choose before first capture + Auto · %1$s + Auto select is available after a platoon profile is detected. Manual timezone Darkwinter Global Darkwinter China @@ -208,6 +212,30 @@ HaoPlay Japan HaoPlay South Korea HaoPlay Asia + New Platoon Detected + GF2logger verified the game client, but the packet does not identify its server. Choose the server before these packets are saved. + Choose this Platoon’s server + Until you confirm, these parsed packets remain only in memory. Force-stopping GF2logger discards them. + %1$s client · Platoon ID %2$d + Discard + Keep choosing + Discard captured packets? + Canceling server selection may prevent these packets from being saved. Discard this unconfirmed Platoon capture? + Manage Platoons + Edit a detected Platoon’s server or permanently delete its isolated data. + %1$s · %2$s · ID %3$d + Active + Edit client/server + Edit server for %1$s + Verified client: %1$s (read-only)\nChoose the server for %2$s. + Stop capture before editing or deleting a Platoon. + Unable to update the Platoon server. No data was moved. + Delete Platoon + Delete %1$s + Deleting %1$s permanently removes its members, weekly tables, settings, checkpoints, and retained evidence. This cannot be undone. + To delete this Platoon, enter its name exactly: %1$s + Enter the exact Platoon name + Unable to finish deleting the Platoon. Cleanup will be retried safely. %1$s · next reset %2$s on this phone (%3$s) Manual · %1$s (phone: %2$s) Game: %1$s · phone: %2$s diff --git a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt index 5162e8a..3257c0d 100644 --- a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt @@ -11,10 +11,13 @@ class BoundedFlowPayloadBufferTest { assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "a")) assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "b")) assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(7, "c")) + assertTrue(buffer.isRejected(7)) assertTrue(buffer.take(7).isEmpty()) + assertTrue(buffer.isRejected(7)) assertEquals(BoundedFlowPayloadBuffer.OfferResult.REJECTED, buffer.offer(7, "d")) buffer.remove(7) + assertTrue(!buffer.isRejected(7)) assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "e")) assertEquals(listOf("e"), buffer.take(7)) } diff --git a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt new file mode 100644 index 0000000..618aa0d --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -0,0 +1,137 @@ +package dev.gf2log.app.capture + +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.PlatoonProfileData +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class PendingPlatoonAdmissionStoreTest { + @After + fun tearDown() = PendingPlatoonAdmissionStore.clearForTests() + + @Test + fun candidateIsMemoryOnlyBoundedAndClaimedAtomically() { + val profile = profile(101817u, "Owls") + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile, + flowId = 7L, + observedAt = Instant.EPOCH, + ).token, + ) + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Accepted, + PendingPlatoonAdmissionStore.offer(token, 7L, payload(profile)), + ) + PendingPlatoonAdmissionStore.markFlowEnded(token, 7L) + + val claim = PendingPlatoonAdmissionStore.claim(token) + assertNotNull(claim) + assertEquals(setOf(7L), claim?.flowIds) + assertEquals(setOf(7L), claim?.endedFlowIds) + assertEquals(1, claim?.payloads?.size) + assertNull(PendingPlatoonAdmissionStore.claim(token)) + + PendingPlatoonAdmissionStore.releaseClaim(token) + assertNotNull(PendingPlatoonAdmissionStore.claim(token)) + assertEquals(setOf(7L), PendingPlatoonAdmissionStore.complete(token)) + assertFalse(PendingPlatoonAdmissionStore.contains(token)) + } + + @Test + fun repeatedIdentitySharesOnePromptWithoutMixingOtherClients() { + val first = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(42u, "First"), + flowId = 1L, + ).token, + ) + val repeated = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(42u, "Updated"), + flowId = 2L, + ).token, + ) + val otherClient = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.DARKWINTER, + profile(42u, "Darkwinter"), + flowId = 3L, + ).token, + ) + + assertEquals(first, repeated) + assertTrue(otherClient != first) + assertEquals(2, PendingPlatoonAdmissionStore.summaries().size) + assertEquals("Updated", PendingPlatoonAdmissionStore.summary(first)?.profile?.platoonName) + } + + @Test + fun overflowingCandidateIsDiscardedInsteadOfPartiallyAdmitted() { + val profile = profile(99u, "Bounded") + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile, + flowId = 9L, + ).token, + ) + repeat(PendingPlatoonAdmissionStore.MAX_PAYLOADS_PER_CANDIDATE) { + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Accepted, + PendingPlatoonAdmissionStore.offer(token, 9L, payload(profile)), + ) + } + val overflow = PendingPlatoonAdmissionStore.offer(token, 9L, payload(profile)) + + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Overflow(setOf(9L)), + overflow, + ) + assertFalse(PendingPlatoonAdmissionStore.contains(token)) + } + + @Test + fun candidateCountRejectsOnlyTheNewFlow() { + repeat(PendingPlatoonAdmissionStore.MAX_CANDIDATES) { index -> + assertNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile((index + 1).toUInt(), "P$index"), + flowId = index.toLong(), + ).token, + ) + } + val rejected = PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(100u, "Extra"), + flowId = 100L, + ) + assertNull(rejected.token) + assertEquals(setOf(100L), rejected.rejectedFlowIds) + } + + private fun profile(id: UInt, name: String) = PlatoonProfileData( + platoonId = id, + platoonName = name, + emblemPrimary = listOf(1u), + emblemSecondary = listOf(2u), + ) + + private fun payload(profile: PlatoonProfileData) = ParsedPayload( + messageId = 1, + payloadType = 21905, + isEndOfMessage = true, + data = profile, + ) +} 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 31b7db7..609db7d 100644 --- a/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/BackupArchiveTest.kt @@ -16,7 +16,7 @@ import org.junit.Test class BackupArchiveTest { @Test - fun `round trips a scoped profile and verifies its deterministic identity`() { + fun `round trips a scoped profile with an immutable storage identity`() { val database = temporaryFile("platoon.db", realisticDatabaseBytes()) val profile = PlatoonProfile( storageId = PlatoonProfileIdentity.storageId( @@ -46,6 +46,37 @@ class BackupArchiveTest { assertEquals(profile.platoonName, result.profile?.platoonName) } + @Test + fun `round trips a profile after its server metadata changes without moving storage`() { + val database = temporaryFile("platoon.db", realisticDatabaseBytes()) + val originalStorage = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ) + val edited = PlatoonProfile( + storageId = originalStorage, + client = PlatoonClient.HAOPLAY, + serverRegion = GameServerRegion.HAOPLAY_JAPAN, + platoonId = 101817L, + platoonName = "Owls", + emblemPrimary = emptyList(), + emblemSecondary = emptyList(), + lastSeenAt = java.time.Instant.EPOCH, + ) + val archive = ByteArrayOutputStream().also { + BackupArchive.write(it, database, realisticSettingsBytes(), edited) + } + + val restored = BackupArchive.stage( + ByteArrayInputStream(archive.toByteArray()), + temporaryPath("edited-scope.db"), + ).profile + + assertEquals(originalStorage, restored?.storageId) + assertEquals(GameServerRegion.HAOPLAY_JAPAN, restored?.serverRegion) + } + @Test fun `round trips a realistic complete archive without changing payload bytes`() { val database = temporaryFile("platoon.db", realisticDatabaseBytes()) diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt index ee851cd..079c385 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt @@ -120,6 +120,35 @@ class MembershipConsistencyPolicyTest { ) } + @Test + fun mixedSameDayStartPrecisionUsesATransitiveOrder() { + val knownLate = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-05-04T18:00:00Z"), + leftAt = Instant.parse("2026-05-04T20:00:00Z"), + joinedDate = LocalDate.of(2026, 5, 4), + leftDate = LocalDate.of(2026, 5, 4), + ) + val dateOnly = MembershipInterval( + id = 2, + joinedAt = Instant.parse("2026-07-31T00:00:00Z"), + leftAt = Instant.parse("2026-08-01T00:00:00Z"), + joinedDate = LocalDate.of(2026, 5, 4), + leftDate = LocalDate.of(2026, 5, 5), + joinedTimeKnown = false, + leftTimeKnown = false, + ) + val knownEarly = MembershipInterval( + id = 3, + joinedAt = Instant.parse("2026-05-04T08:00:00Z"), + leftAt = Instant.parse("2026-05-04T10:00:00Z"), + joinedDate = LocalDate.of(2026, 5, 4), + leftDate = LocalDate.of(2026, 5, 4), + ) + + assertNull(MembershipConsistencyPolicy.violation(listOf(knownLate, dateOnly, knownEarly))) + } + private fun interval(id: Long, joined: String?, left: String?) = MembershipInterval( id = id, joinedAt = joined?.let(Instant::parse), diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt new file mode 100644 index 0000000..59f5450 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlatoonProfileRestoreJournalTest { + @Test + fun journalRoundTripsUnicodeProfileAndRoutingRollback() { + val profile = PlatoonProfile( + storageId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ), + client = PlatoonClient.HAOPLAY, + serverRegion = GameServerRegion.HAOPLAY_KOREA, + platoonId = 101817L, + platoonName = "부엉이", + emblemPrimary = listOf(1, 2), + emblemSecondary = listOf(3), + lastSeenAt = Instant.parse("2026-08-24T12:00:00Z"), + ) + val value = PlatoonProfileRestoreJournal( + targetStorageId = profile.storageId, + previousProfile = profile, + previousActiveStorageId = profile.storageId, + ownerPackage = profile.client.packageName, + previousCaptureRegion = GameServerRegion.HAOPLAY_JAPAN, + ) + + assertEquals(value, PlatoonProfileRestoreJournalCodec.decode( + PlatoonProfileRestoreJournalCodec.encode(value), + )) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa4f2bc..316767f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,13 +69,36 @@ Payload `21905` is the authoritative Platoon identity for the decoded flow. On Android 10 and newer, the capture service also resolves the original connection tuple to the owning supported package through Android's VPN owner API. Remote IP addresses and DNS/SNI labels are diagnostic endpoint metadata only; they are -not persistence keys. Android 8–9 falls back only when exactly one supported -client is installed. Management payloads are quarantined until a flow has both -a verified supported owner and valid `21905`. The composite -`(client package, selected server region, Platoon ID)` is hashed into a stable -private storage ID used by every database, import, retained CSV, checkpoint, -weekly setting, and backup path. Existing v2.3.x storage remains in place as a -legacy profile instead of being copied or destructively migrated. +not currently collected as server identity and are not persistence keys. +Android 8–9 falls back only when exactly one supported client is installed. +Management payloads are not persisted until a flow has both a verified +supported owner and valid `21905`. + +For a previously unknown `(client package, Platoon ID)`, decoded payloads enter +a bounded process-memory admission queue. Returning to the app presents the +observed Platoon name, ID, and verified client, then requires one compatible +server choice. Confirmation creates the profile and replays its buffered +payloads through the normal scoped ingestion pipeline. Explicit discard, +candidate overflow, force-stop, or process death removes the unconfirmed data; +it never enters parsed-packet history, SQLite, retained CSV, or preferences. +HaoPlay candidates expose only its four known regions and Darkwinter candidates +only its two known regions. + +The first confirmed identity receives an immutable private storage ID. The ID +is initially derived from `(client package, selected server region, Platoon ID)` +when available, with a random collision-safe fallback, but later server edits do +not rename or move the scope. Every database, import, retained CSV, checkpoint, +weekly setting, and backup path remains bound to that opaque ID. Client identity +is read-only because it comes from Android's VPN owner API; server metadata can +be corrected only within that client's compatible regions. Existing v2.3.x +storage remains in place as a legacy profile instead of being copied or +destructively migrated. + +Choosing an existing profile synchronizes that client's capture preset to the +profile's saved region; the report reset follows it automatically. Reliable +future first-capture inference would require bounded DNS/SNI/Host correlation +and an allowlisted, independently verified hostname-to-region map. Reverse DNS, +IP geolocation, and CDN addresses are not accepted as identity evidence. ## Memory and concurrency limits @@ -90,11 +113,13 @@ legacy profile instead of being copied or destructively migrated. - Before identity is established, each flow retains at most 32 decoded payload objects. Overflow rejects that flow until closure. The profile registry holds at most 16 bounded identities. +- Unconfirmed profile admission is capped at 4 candidates, 64 decoded payloads + per candidate, and 128 payloads total. Overflow rejects every flow belonging + to that candidate rather than partially persisting it. - A bound flow cannot change its Platoon identity. Admission failure clears its session and quarantines the flow until native closure. One user-started - capture may admit at most one new profile per supported client; users can - forget a detected profile from the selector to recover registry capacity - without deleting its isolated database or retained evidence. + capture may admit at most one new profile per supported client. Explicit + profile deletion removes one immutable scope behind two confirmations. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. - Outgoing plaintext chunks are used only for native flow classification. - Flow-close callbacks finalize any pending recognized payload before removing parser state and @@ -219,8 +244,9 @@ non-exported `OnboardingActivity`. The five-page guide may be finished or skipped, and writes completion only at that explicit exit. Its English/Korean segmented control persists the same language preference used by Settings. Complete backup settings schema v4 includes language, theme, onboarding -completion, the server-region reset preset, and the persisted manual game -timezone. Schema-v1 through schema-v3 backups remain accepted with safe +completion, and the server-region reset preset. Historical manual timezone +values remain decodable for backup compatibility but are no longer offered in +the UI. Schema-v1 through schema-v3 backups remain accepted with safe defaults and completed onboarding defaults so an experienced restoring user is not trapped in the guide. @@ -232,9 +258,17 @@ capture maintains its required-payload checklist per scope so observations from two clients cannot be combined into a false completion. CSV preview and apply retain the same immutable scope even if an Activity is -recreated. A scoped backup is validated before profile metadata changes; after -the database transaction commits, restore selects the archived profile and -aligns that publisher's future capture-region routing with the archived server. +recreated. A scoped backup is validated before profile metadata changes. Its +database, settings, retained CSV retirement, registry metadata, active profile, +and client-region routing then commit under one durable journal; interrupted +restores roll all of those resources back together. + +Profile server edits retain the immutable scope and rebuild weekly history under +the repository's exclusive database gate. Full profile deletion uses a durable +pending-deletion set: selector metadata is removed first, then the scoped SQLite +database, retained evidence, member ordering, weekly cutlines, and reset setting +are deleted. If the process stops mid-cleanup, registry initialization resumes +the remaining bounded deletion before showing profiles. The design deliberately favors composition over deep inheritance. Abstraction and polymorphism appear at real variation points (`GameData`, `ParseEvent`, and @@ -265,10 +299,12 @@ selected state without reading, validating, or replacing app settings. Format v2 adds a checksummed, strictly typed settings payload containing only user-owned configuration; capture diagnostics, raw packet history, signing material, and internal migration flags -are excluded. Format v3 binds either archive scope to the deterministic client, -server-region, and Platoon identity. Restoring v3 creates or replaces only that -profile and then selects it; other profile databases and evidence directories -are untouched. Older v1/v2 archives restore into the unmoved legacy profile. +are excluded. Format v3 binds an archive to its immutable storage scope and its +verified client, server-region, and Platoon metadata. Restoring v3 creates or +replaces only that compatible profile and then selects it; another storage scope +cannot claim the same full identity, and other profile databases and evidence +directories are untouched. Older v1/v2 archives restore into the unmoved legacy +profile. Complete restore validates the filename, archive entries and identity, checksums, settings completeness and ranges, current database schema, SQLite diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 410bde2..2485950 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -35,8 +35,15 @@ rules below take precedence over stale copied dates in archived templates. - Period calculations use the selected server region's fixed reset-zone offset. The known Darkwinter Global/China and HaoPlay Global/Japan/Korea/Asia presets all reset at 05:00 server time, and Settings converts the next reset to the - Android device timezone for display. Manual mode uses the selected game - timezone, initially the phone timezone. Stored capture instants remain UTC. + Android device timezone for display. Selecting an existing Platoon follows + its saved preset automatically; stored capture instants remain UTC. +- A newly observed Platoon is not assigned a guessed region. Its decoded packets + remain in a bounded in-memory candidate until the user selects one of the six + supported regions compatible with the VPN-owner-verified client. Only then is + its immutable scoped repository created and the buffered evidence ingested. +- Changing a profile's server rebuilds its weekly history without changing its + storage scope. Deleting a profile removes only that scope after two warnings + and an exact Platoon-name confirmation; interrupted cleanup resumes later. ## Merit calculation